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

EventFires when
workflow_run.succeededthe run finished; its assets are ready
workflow_run.failedthe run stopped; error says why
workflow_run.needs_inputthe run is waiting for a person in Morphic Studio
workflow_run.expiredthe 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 2xx within 10 seconds. Acknowledge first, then do the work — a slow handler reads as a failure.
  • A non-2xx or 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-Id and ignore one you have already handled.
  • A succeeded delivery 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.

On this page