Webhooks

Get a signed notice when a Direct API generation finishes, verify it, and read the generation for its details.

A webhook is an HTTPS endpoint of yours that Morphic calls when a generation reaches a final status. Deliveries are signed, retried for about a day, and logged. Each event is a thin notice: it says which generation finished and how, and you read the generation with your key for its assets, billing and error message.

Register an endpoint

POST /v1/direct/webhooks needs the generations:write scope.

curl -s https://mcp.morphic.com/v1/direct/webhooks \
  -H "Authorization: Bearer $MORPHIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://hooks.example.com/morphic",
        "events": ["generation.succeeded", "generation.failed", "generation.cancelled"],
        "description": "Production order pipeline"
      }'
FieldRules
urlHTTPS on a public host (no IP literals, no private or local addresses), up to 2,048 characters
eventsone or more event types
descriptionoptional, up to 200 characters

The response is 201:

{
  "webhook": {
    "id": "01929a10-0c3d-7f44-8a21-0b1c2d3e4f50",
    "url": "https://hooks.example.com/morphic",
    "events": ["generation.succeeded", "generation.failed", "generation.cancelled"],
    "description": "Production order pipeline",
    "created_at": "2026-10-25T08:00:00.000Z",
    "signing_secret": "whsec_4b1e…"
  }
}
  • signing_secret is shown only in this response. Store it as you would the API key.
  • To rotate the secret, register a new endpoint, switch your calls to its id, then delete the old one.
  • An organization may hold up to 10 endpoints.

Manage endpoints

RequestScopeDoes
GET /v1/direct/webhooksassets:readlists endpoints as { "webhooks": [ … ] }, never with their secrets
DELETE /v1/direct/webhooks/{webhook_id}generations:writeremoves the endpoint; deliveries still pending to it are abandoned

Attach an endpoint to a call

Pass webhook_id in the tool call body. Every generation that call starts is delivered to that endpoint when it reaches a final status the endpoint subscribed to. Calls without webhook_id send no webhooks.

The tools that render video on Morphic's servers (video_compose, the video_* transforms and burn_subtitles) do not accept webhook_id yet; poll their generations.

Event types

EventSent when
generation.succeededthe generation finished with output
generation.failedthe generation failed; its charge is refunded
generation.cancelledthe generation was cancelled; its charge is refunded
  • Each generation produces at most one event.
  • queued and in_progress are not delivered; poll for those if you need them.
  • A generation is watched for 72 hours after it starts. One that ends later sends no event, so read it instead.

The delivery

POST /morphic HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Morphic-Webhooks/1.0
Morphic-Event-Id: 0192a7c0-5d4e-7b3a-8c2d-1e0f9a8b7c6d
Morphic-Delivery-Id: 0192a7c0-6e5f-7c4b-9d3e-2f1a0b9c8d7e
Morphic-Event-Type: generation.succeeded
Morphic-Signature: t=1792919741,v1=5f8a0c…e21b

{
  "id": "0192a7c0-5d4e-7b3a-8c2d-1e0f9a8b7c6d",
  "type": "generation.succeeded",
  "created_at": "2026-10-25T09:15:42.410Z",
  "api_version": "v1",
  "data": {
    "generation": {
      "object": "generation",
      "id": "0192a7b3-a1c4-7e02-8f9d-1a2b3c4d5e6f",
      "tool": "generate_video",
      "status": "succeeded",
      "completed_at": "2026-10-25T09:15:41.902Z",
      "error_code": null,
      "metadata": { "order_id": "4411" }
    }
  }
}

data.generation carries only these fields:

FieldMeaning
object, id, tool, status, completed_at, metadataas in the generation object
error_codenull, or the code of a failed generation

An event never carries outputs, links, billing, the model or the error message, so a delivery that lands in the wrong place or in a log gives nothing away. On receipt, read the generation with your key: GET /v1/direct/generations/{id}, or the batch read for several. By the time an event is sent nothing more is due on its billing, so that read is final.

HeaderMeaning
Morphic-Event-Idthe event's id, identical on every retry; deduplicate on it
Morphic-Delivery-Idthis one attempt; differs per retry
Morphic-Event-Typethe type of the body
Morphic-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

An event is typically sent within about a minute of the generation reaching its final status. It is not a real-time stream: for the lowest latency on one generation you are waiting on, long-poll it.

Verify the signature

v1 is the hex HMAC-SHA256, keyed by your endpoint's signing_secret, of the string {t}.{raw request body}.

  1. Split the header on , into t and v1.
  2. Compute HMAC_SHA256(secret, t + "." + raw_body) over the raw bytes you received, before any JSON parsing or re-serialisation.
  3. Compare it to v1 in constant time.
  4. Reject the delivery if t is more than 5 minutes from now, to stop replays.
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/morphic', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Morphic-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', process.env.MORPHIC_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex');

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  const valid =
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

  if (!fresh || !valid) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  enqueue(event); // process asynchronously, keyed by event.id
  res.sendStatus(204);
});

An unverified endpoint is an open door: anyone who learns its URL can tell you a generation finished. Verify every signature and reject a timestamp more than 5 minutes old.

Responding and retries

  • Answer with any 2xx within 10 seconds, then do the work.
  • Anything else is a failed attempt: a non-2xx status, a timeout, a refused connection, a TLS error.
  • Redirects are not followed; a 3xx is a failed attempt.
  • Order is not guaranteed and an event may arrive more than once. Make your handler idempotent on Morphic-Event-Id, or on data.generation.id, since a generation has at most one event.

A failed attempt is retried after:

Retry1234567
Wait after the previous attempt1 min5 min15 min1 h3 h6 h12 h

After the 7th retry, about 22 hours after the first attempt, the event is abandoned. The generation itself is unaffected: read it with GET /v1/direct/generations/{id}.

Delivery log

GET /v1/direct/webhooks/{webhook_id}/events (scope assets:read) returns the most recent 20 events for the endpoint, each with the exact body sent and every attempt's outcome. Add ?generation_id=… for one generation's events.

{
  "webhook_events": [
    {
      "id": "0192a7c0-5d4e-…",
      "type": "generation.succeeded",
      "generation_id": "0192a7b3-…",
      "status": "delivered",
      "created_at": "2026-10-25T09:15:42.410Z",
      "payload": { "…": "the body that was sent" },
      "attempts": [
        { "id": "0192a7c0-6e5f-…", "attempted_at": "2026-10-25T09:15:42.900Z", "response_status": 204, "duration_ms": 131, "error": null, "response_body_excerpt": null }
      ]
    }
  ]
}
Event statusMeaning
pendingnot delivered yet; attempts may still follow
deliveredyour endpoint answered 2xx
abandonedthe last retry failed, or the endpoint was removed
skippedthe endpoint did not subscribe to that event type

Webhooks are the cheaper, faster signal, not a guarantee. Keep an occasional poll of generations you have not heard about as a safety net.


If you have any trouble or have any questions, feel free to reach out. We're happy to help. Write to us at support@morphic.com.

On this page