Skip to content

Webhooks

Configure a webhook on a service when your application prefers a callback over polling. Every run of that service is reported to your endpoint with a signed POST once it reaches a terminal state. The stored run remains the source of truth; delivery is best effort with retries.

In the console, open the service, scroll to Webhook in its settings, enter an HTTPS endpoint URL and a signing secret (or press Generate), then save. The secret is stored encrypted and never shown again; enter a new value to rotate it.

With the SDK:

await magmell.patchServiceSettings('reviewer', {
webhookUrl: 'https://example.com/hooks/magmell',
webhookSecret: '<16-256 printable ASCII characters>',
})

Rules for the URL: https:// only, a public hostname or address, no credentials in the URL, at most 2048 bytes. Targets that resolve to private, loopback, link-local, metadata, multicast or other special-use addresses are rejected when you configure them and again before every delivery. Redirects are not followed.

Removing the webhook (webhookUrl: null, or Remove webhook in the console) clears the secret as well. Deliveries still pending at that moment are skipped.

Send test event in the console, or await magmell.testWebhook('reviewer'), delivers a webhook.test payload through exactly the same signing path as a real report and shows the result: the HTTP status your endpoint returned and the round-trip time, or the error class (timeout, connect_error, dns, blocked_target, …). Use it to verify your signature check before relying on the webhook. Test events and webhook URL changes are each limited to ten per minute per team.

{
"event": "run.succeeded",
"run_id": "8ebea6be-3b54-40ca-b8b5-5176264f2450",
"run_number": 42,
"service": "reviewer",
"deployment_revision": 3,
"session_key": null,
"status": "succeeded",
"result": { "summary": "..." },
"result_omitted": false,
"error": null,
"queued_at": "2026-09-03T14:32:01.104512+00:00",
"started_at": "2026-09-03T14:32:03.517209+00:00",
"finished_at": "2026-09-03T14:32:07.882014+00:00"
}

event is run.succeeded or run.failed; a failed run carries its redacted message in error. A result larger than 256 KiB as stored is not included: result is null and result_omitted is true, and you fetch the run instead. A test event has the same keys with event: "webhook.test" and nulls elsewhere.

Every request carries:

Content-Type: application/json
User-Agent: magmell-webhook/1
X-Magmell-Event: run.succeeded
X-Magmell-Timestamp: 1788791527
X-Magmell-Signature: v1=<hex digest>

The signature is HMAC-SHA256, keyed with your signing secret, over the timestamp, a period, and the exact raw request body. Compute it from the raw bytes before you parse anything:

import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(body: Buffer, timestamp: string, received: string, secret: string) {
const expected = 'v1=' + createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(body)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(received)
return a.length === b.length && timingSafeEqual(a, b)
}

Reject requests whose X-Magmell-Timestamp is outside your replay window (five minutes is a reasonable default). Rotating the secret applies to every delivery attempted after the change, including retries of earlier runs.

A 2xx response marks the report delivered. Any other status, a timeout, a connection error or an unresolvable host is retried with backoff: 10 s, 40 s, 160 s, 640 s and 900 s, six attempts in total over roughly half an hour, after which the run’s delivery is failed. Your endpoint has ten seconds to answer; the response body is never read. Deliveries are made one at a time per team, in due order, so a slow endpoint on one of your services delays only your own reports.

Delivery is at-least-once. A retry can deliver the same completion more than once, so make your receiver idempotent using run_id.

The run record shows the delivery state under webhook: status (pending, delivered, failed or skipped), attempts, the last HTTP status, and the last error class. The console shows the same line on the run’s page.

Runs accepted while no webhook was configured are never reported, even if you configure one later.