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"
}'| Field | Rules |
|---|---|
url | HTTPS on a public host (no IP literals, no private or local addresses), up to 2,048 characters |
events | one or more event types |
description | optional, 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_secretis 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
| Request | Scope | Does |
|---|---|---|
GET /v1/direct/webhooks | assets:read | lists endpoints as { "webhooks": [ … ] }, never with their secrets |
DELETE /v1/direct/webhooks/{webhook_id} | generations:write | removes 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
| Event | Sent when |
|---|---|
generation.succeeded | the generation finished with output |
generation.failed | the generation failed; its charge is refunded |
generation.cancelled | the generation was cancelled; its charge is refunded |
- Each generation produces at most one event.
queuedandin_progressare 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:
| Field | Meaning |
|---|---|
object, id, tool, status, completed_at, metadata | as in the generation object |
error_code | null, 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.
| Header | Meaning |
|---|---|
Morphic-Event-Id | the event's id, identical on every retry; deduplicate on it |
Morphic-Delivery-Id | this one attempt; differs per retry |
Morphic-Event-Type | the type of the body |
Morphic-Signature | t=<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}.
- Split the header on
,intotandv1. - Compute
HMAC_SHA256(secret, t + "." + raw_body)over the raw bytes you received, before any JSON parsing or re-serialisation. - Compare it to
v1in constant time. - Reject the delivery if
tis 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
2xxwithin 10 seconds, then do the work. - Anything else is a failed attempt: a non-
2xxstatus, a timeout, a refused connection, a TLS error. - Redirects are not followed; a
3xxis a failed attempt. - Order is not guaranteed and an event may arrive more than once. Make your handler idempotent on
Morphic-Event-Id, or ondata.generation.id, since a generation has at most one event.
A failed attempt is retried after:
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Wait after the previous attempt | 1 min | 5 min | 15 min | 1 h | 3 h | 6 h | 12 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 status | Meaning |
|---|---|
pending | not delivered yet; attempts may still follow |
delivered | your endpoint answered 2xx |
abandoned | the last retry failed, or the endpoint was removed |
skipped | the 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.