---
title: Webhooks
description: Have Sume call your server when a job finishes.
---
Set `mode: "webhook"` and a `webhook_url` on any generate request, and Sume POSTs the outcome to that URL when the job reaches a terminal state.

```json
{
  "model": "gpt-image-2",
  "prompt": "A ceramic coffee cup on a wooden table",
  "mode": "webhook",
  "webhook_url": "https://example.com/hooks/sume"
}
```

The URL must be public HTTPS. Localhost, private-network and plain HTTP URLs are rejected when you submit.

## Events

Sume sends terminal events only, one per job:

| Event | When |
|---|---|
| `job.completed` | The job completed. `payload.artifacts` holds the output. |
| `job.failed` | The job failed. `error` says why. |
| `job.canceled` | The job was canceled. |

```json
{
  "event": "job.completed",
  "request_id": "job_...",
  "job_id": "job_...",
  "status": "OK",
  "payload": {
    "artifacts": [
      {
        "id": "artf_...",
        "url": "https://media.sume.com/artifacts/...",
        "type": "image",
        "content_type": "image/png"
      }
    ]
  }
}
```

Failed and canceled events use `"status": "ERROR"` and carry an `error` object.

## Verify the signature

Every delivery is signed. Sume computes HMAC-SHA256 over `<timestamp>.<raw body>` with your workspace's signing secret and sends:

```http
x-sume-webhook-timestamp: 1780000000
x-sume-webhook-signature: sume-v1=<hex>
```

Read the signing secret on the **Webhooks** tab of the dashboard. During a secret rotation the signature header holds one `sume-v1=` entry per live secret, separated by commas; accept the delivery if any entry matches. Reject deliveries whose timestamp is more than five minutes old.

```ts
import crypto from "node:crypto";

export function verifySumeWebhook(rawBody: string, timestamp: string, header: string, secret: string) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return header.split(",").some(entry => {
    const value = entry.trim().replace(/^sume-v1=/, "");
    return value.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}
```

## Respond and dedupe

Answer with any `2xx` after you have stored the event. A delivery can arrive more than once; dedupe on `job_id`. If your endpoint is down, read the job with [Get job status](/docs/reference/jobs/status): the job's `webhook_delivery` field records what was sent.

A delivery that gets no `2xx` is tried again up to 10 times in all: 30 seconds after the first try, then twice as long after each one, never more than an hour apart, about three hours in total. After the last try, `POST /v1/jobs/{id}/webhook/redeliver` sends it again.
