Skip to navigation

Webhooks

A signed POST when a job settles. Verify it, dedupe on event.id, and keep polling authoritative.

Webhooks are turned on per account. Where they are not on for yours, the Platform has no webhook endpoints page and nothing is sent. Polling GET /v1/jobs/{job_id} works everywhere and stays the contract.

A webhook is additive: it delivers the same Job that polling returns, at the moment the job reaches a terminal state, so you can stop polling once you have it. It is never the only way to learn a result.

You register endpoints on the Webhooks page of the Platform, per environment (live or test). Each endpoint has an HTTPS URL on port 443, the event types it subscribes to, and a signing secret that is shown once, when you create or rotate it.

Events

job.succeeded the job reached succeeded
job.failed the job reached failed
job.cancelled the job reached cancelled
test.ping you pressed "Send test event" in the console

A job emits exactly one terminal event.

The envelope

{
"id": "evt_0h2k9t7vd8x3m1c5q6r2w4z8ya",
"object": "event",
"type": "job.succeeded",
"api_version": "v1",
"created": "2026-10-06T12:00:00Z",
"data": {
"object": { "id": "job_...", "object": "job", "status": "succeeded", "...": "..." }
}
}
  • data.object is the Job exactly as GET /v1/jobs/{job_id} returns it. Output assets are href plus expires_at, as on the poll; after expiry the href answers 404.
  • created is ISO-8601 UTC, like every V1 timestamp.
  • livemode is present only where test-mode keys are available, and matches the Job’s environment.
  • For test.ping, data.object is {"object": "test", "endpoint_id": "..."}.
  • If the job is deleted before a delivery or resend goes out, data.object is {"id": "job_...", "object": "job", "deleted": true} and nothing else.
  • The body carries no input image, no email, no account identifier, and no presigned URL.

The request

POST /your/path
Content-Type: application/json
User-Agent: MorphiaOne-Webhooks/1
MorphiaOne-Event-Id: evt_0h2k9t7vd8x3m1c5q6r2w4z8ya
MorphiaOne-Event-Type: job.succeeded
MorphiaOne-Delivery-Attempt: 3
MorphiaOne-Signature: t=1791288000,v1=5257a8...[,v1=9f1c02...]

Verify every delivery

The signature covers the timestamp and the raw body:

v1 = hex( HMAC-SHA256( secret, "<t>." + raw_body ) )

A verifier must:

  • read t exactly once and at least one v1 from the header, ignoring any other scheme;
  • refuse a t more than 300 seconds from its own clock, in either direction;
  • accept the delivery if any v1 matches, comparing in constant time;
  • parse the JSON only after the signature verified.

Hand the verifier the body bytes as received: a body that your framework parsed and re-serialized will not verify.

Without an SDK

The check needs only your language’s standard library, so it works with any SDK version or none:

import { createHmac, timingSafeEqual } from "node:crypto";
export function signatureIsValid(rawBody: Buffer, header: string | null, secret: string): boolean {
if (!header) return false;
let timestamp: string | undefined;
const candidates: string[] = [];
for (const item of header.split(",")) {
const separator = item.indexOf("=");
if (separator < 0) continue;
const name = item.slice(0, separator).trim();
const value = item.slice(separator + 1).trim();
if (name === "t") {
if (timestamp !== undefined || !/^\d+$/.test(value)) return false;
timestamp = value;
} else if (name === "v1") {
candidates.push(value);
}
}
if (timestamp === undefined || candidates.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
);
return candidates.some((candidate) => {
const received = Buffer.from(candidate);
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

During a rotation, call it once per secret you hold and accept if either is valid.

With an SDK

Both SDKs do exactly the checks above and return the parsed event.

import { SIGNATURE_HEADER, WebhookSignatureError, verifyWebhook } from '@morphiaone/sdk';
export function handle(rawBody: Uint8Array, headers: Headers): number {
let event;
try {
event = verifyWebhook({
payload: rawBody,
signature: headers.get(SIGNATURE_HEADER),
secret: endpointSecret, // or [newSecret, oldSecret]
});
} catch (error) {
if (error instanceof WebhookSignatureError) return 400;
throw error;
}
if (alreadyProcessed(event.id)) return 200;
enqueue(event); // do the work after answering
return 200;
}

tolerance_s / toleranceSeconds narrows the 300-second window if you want; now lets a test pin the clock. The decoded Job is morphiaone.v1.parse_job(event.data["object"]) in Python and parseJob(event.data.object) in TypeScript.

Delivery guarantees

  • At least once. A delivery can arrive more than once. event.id is stable across retries and console resends, so dedupe on it.
  • No ordering. Events for different jobs can arrive in any order. Each job emits one terminal event, so ordering never matters within a job.
  • Answer fast. Any 2xx within 10 seconds is a success. Anything else — a 3xx (redirects are not followed), a 4xx, a 5xx, a timeout, a TLS or connection failure — is a failed attempt. Acknowledge first and process asynchronously.
  • Polling stays authoritative. If a webhook is late, missing or disagrees with GET /v1/jobs/{job_id}, the poll is right.

Retries

A failed attempt is retried, up to 8 attempts in total. The waits before attempts 2 to 8 are about 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours, each with up to 20% random jitter either way. No attempt is scheduled more than 24 hours after the event was created; a delivery that has not succeeded by then stops, and the console shows it as exhausted. The MorphiaOne-Delivery-Attempt header counts from 1.

Automatic disabling

An endpoint that has failed 20 attempts in a row and has been failing for at least 24 hours is disabled. Its pending deliveries are cancelled, and the console shows a banner. Re-enabling it is your action: it resets the failure count, and it does not revive cancelled deliveries — resend those from the delivery log.

Rotating the secret

Rotate on the endpoint’s page in the Platform. The new secret is shown once. You choose how long the previous secret stays valid (up to 7 days); until then every delivery carries two v1 values, one per secret, so a receiver holding either verifies. Deploy the new secret, then let the old one expire. If you hold both on your side, pass both: the SDKs accept a list.

Test events and the delivery log

“Send test event” sends one test.ping to the endpoint, signed like any other delivery and attempted once. Every delivery is listed per endpoint with its attempts, status codes and timings, and any delivery can be resent with the same event.id.