> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.morphiaone.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.morphiaone.ai/_mcp/server.

# Webhooks

> **Note**
>
> 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](/concepts/jobs) 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](https://platform.morphiaone.ai/login), 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

```text
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

```json
{
  "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

```text
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:

```text
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:

**`TypeScript`**

```ts title="TypeScript"
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);
  });
}
```

**`Python`**

```python title="Python"
import hashlib
import hmac
import time


def signature_is_valid(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    timestamp, candidates = None, []
    for item in header.split(","):
        name, sep, value = item.strip().partition("=")
        if not sep:
            continue
        if name == "t":
            if timestamp is not None or not value.isdigit():
                return False
            timestamp = value
        elif name == "v1":
            candidates.append(value)
    if timestamp is None or not candidates:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
```

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.

**`TypeScript`**

```ts title="TypeScript"
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;
}
```

**`Python`**

```python title="Python"
from morphiaone import WebhookSignatureError, webhooks

def handle(raw_body: bytes, headers) -> int:
    try:
        event = webhooks.verify(
            raw_body,
            headers.get("MorphiaOne-Signature"),
            endpoint_secret,          # or [new_secret, old_secret]
        )
    except WebhookSignatureError:
        return 400
    if already_processed(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`.