Webhooks
Be told when a run finishes instead of polling for it.
Register an endpoint once, then pass its webhook_id when you start a run and we call you when that run changes to a state you subscribed to — usually within a minute.
Register an endpoint
POST {base_url}/webhooks
{
"url": "https://hooks.example.com/morphic",
"events": ["workflow_run.succeeded", "workflow_run.failed"],
"description": "story pipeline"
}Requires runs:write. The URL must be public https with no credentials in it; an organization holds up to 10 endpoints.
The 201 response carries the endpoint's id — pass it as webhook_id on a run — and its signing secret (whsec_…), shown once. Store it as you would the API key.
GET {base_url}/webhooks
DELETE {base_url}/webhooks/{webhook_id}GET (runs:read) lists your endpoints, never their secrets. DELETE (runs:write) removes one; deliveries still pending for it are dropped. To rotate a secret, register a new endpoint and move your runs to it.
Events
| Event | Fires when |
|---|---|
workflow_run.succeeded | the run finished; its assets are ready |
workflow_run.failed | the run stopped; error says why |
workflow_run.needs_input | the run is waiting for a person in Morphic Studio |
workflow_run.expired | the run never finished inside its lifetime |
The delivery
POST https://hooks.example.com/morphic
Morphic-Event-Id: 0192f4a0-3c71-7b92-8e14-5d6f2a9b3c07
Morphic-Delivery-Id: 0192f4a0-4d82-7c03-9f25-6e7a3b0c4d18
Morphic-Event-Type: workflow_run.succeeded
Morphic-Signature: t=1790056632,v1=3f9c1a…
{
"id": "0192f4a0-3c71-7b92-8e14-5d6f2a9b3c07",
"type": "workflow_run.succeeded",
"created_at": "2026-09-22T04:17:12Z",
"api_version": "v1",
"data": {
"run": { "id": "0192f3d0-…", "status": "succeeded", "assets": [], "credits_used": 1792 }
}
}The run object is the same shape Read a workflow run returns, assets included — so a succeeded delivery needs no follow-up call. It is the run as it stood when the event was raised, and its asset links stay valid for about a day, as long as the delivery's retries.
Verify every delivery
Compute an HMAC-SHA256 of {timestamp}.{raw body} with your signing secret and compare it to v1 in constant time. Use the raw body, before any JSON parsing.
import crypto from 'node:crypto';
function verify({ rawBody, header, secret }) {
const parts = Object.fromEntries(
header.split(',').map((pair) => pair.split('=')),
);
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const signed = Buffer.from(expected);
const given = Buffer.from(parts.v1);
if (signed.length !== given.length) return false;
if (!crypto.timingSafeEqual(signed, given)) return false;
// Refuse a replayed delivery.
return Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
}An unverified webhook endpoint is an open door: anyone who learns the URL can tell you a run succeeded. Verify the signature and reject a timestamp older than five minutes.
Retries and ordering
- Answer
2xxwithin 10 seconds. Acknowledge first, then do the work — a slow handler reads as a failure. - A non-
2xxor a timeout is retried with exponential backoff for about a day, then dropped. A redirect is not followed, and counts as a failure. - Only the events an endpoint subscribed to are sent; a run that reaches another state reports nothing for it.
- Deliveries can arrive out of order and more than once. Key on
Morphic-Event-Idand ignore one you have already handled. - A
succeededdelivery can arrive before your own record of the run exists. Handle that rather than erroring — or fall back to polling for those runs.
Webhooks are an optimization, not a guarantee. Keep a slow sweep that polls runs you have not heard about, so one bad deploy on your side does not lose a day's output.