> ## Documentation Index
> Fetch the complete documentation index at: https://docs.voyager.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get an HTTPS callback when a job succeeds, fails or is canceled, signed per Standard Webhooks with your key's whsec_ secret.

Pass a `webhook_url` when you create a job and Voyager sends it a signed `POST` when the job finishes. Use webhooks to avoid holding a poll open for long jobs such as video and 3D.

```bash theme={"system"}
curl https://api.voyager.so/v1/jobs \
  -H "Authorization: Bearer $VOYAGER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "nano-banana-2.1",
    "operation": "text-to-image",
    "input": { "prompt": "a red fox in snow" },
    "webhook_url": "https://example.com/hooks/voyager"
  }'
```

`webhook_url` must be a public `https://` URL of up to 2,048 characters, with no credentials and not a private or local address. Voyager checks it when you create the job (`400` [`invalid_webhook_url`](https://docs.voyager.so/api/errors#invalid_webhook_url) if it isn't) and again on every delivery. It doesn't follow redirects: a `3xx` counts as a failed delivery.

## Events

| Event | When |
| - | - |
| `job.succeeded` | A job finished with its outputs. |
| `job.failed` | A job failed (error.code says why; outcome\_unknown means a new submission may charge again). |
| `job.canceled` | A job was canceled (error.code queue\_timeout when it waited in the queue too long). |

Webhooks fire once per job, when it reaches a terminal status (`job.failed` with [`outcome_unknown`](https://docs.voyager.so/api/errors#outcome_unknown) means Voyager couldn't confirm the generation: ask for its status again, and never submit it again, or it may be charged twice). They don't fire for `queued` or `running`, and they don't fire again when the job's charge settles later: read `credits.state` with `GET /v1/jobs/{id}` if you need the final charge.

## Payload

```json theme={"system"}
{
  "id": "evt_01JC4QC2T6W8Y0A3C5E7G9J1LN",
  "type": "job.succeeded",
  "created_at": "2026-11-05T21:09:56Z",
  "data": {
    "id": "job_01JC4Q8ZK3V0B7M2X9T5R6N4PA",
    "object": "job",
    "type": "image",
    "model": "nano-banana-2.1",
    "operation": "text-to-image",
    "status": "succeeded",
    "input": { "prompt": "a red fox in snow" },
    "outputs": [ { "id": "file_01JC4QB3N8D5H1W6Y2K7M0F9EC", "object": "file", "type": "image", "mime_type": "image/png", "url": "https://…", "url_expires_at": "2026-11-05T22:09:56Z" } ],
    "credits": { "estimated": …, "held": …, "charged": null, "released": null, "state": "pending", "charge_ref": "…" },
    "error": null,
    "metadata": { "shot": "12" },
    "created_at": "2026-11-05T21:09:41Z",
    "started_at": "2026-11-05T21:09:42Z",
    "completed_at": "2026-11-05T21:09:55Z",
    "poll_after_ms": null,
    "links": { "self": "/v1/jobs/job_01JC4Q8ZK3V0B7M2X9T5R6N4PA", "cancel": "/v1/jobs/job_01JC4Q8ZK3V0B7M2X9T5R6N4PA/cancel" }
  }
}
```

`data` is the full [job](https://docs.voyager.so/api/jobs.md#the-job-object), rendered fresh for each delivery attempt. Its output links expire one hour after that; if you download later, call `GET /v1/jobs/{id}` for fresh ones.

## Headers

Voyager follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification:

| Header | Value |
| - | - |
| `webhook-id` | The event's id (`evt_…`, the body's `id`), stable across retries: deduplicate on it. |
| `webhook-timestamp` | Unix seconds when this attempt was sent. Reject old timestamps to stop replays. |
| `webhook-signature` | `v1,<base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{raw body}">` keyed with the base64-decoded part of the key's `whsec_` secret. May hold several space-separated signatures; accept the request if any one matches. |

## The signing secret

Each API key has its own webhook secret, `whsec_…`, shown once next to the key when you create it on [voyager.so/dashboard/api](https://voyager.so/dashboard/api). Jobs created with a key are signed with that key's secret. Store it like the key itself. If you lose it, create a new key.

## Verify the signature

Always verify before trusting the body. Use the **raw** request body: parsing and re-serializing the JSON changes the bytes and breaks the signature.

```python Python theme={"system"}
# pip install standardwebhooks flask
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["VOYAGER_WEBHOOK_SECRET"])  # whsec_...


@app.post("/hooks/voyager")
def voyager_hook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 400
    job = event["data"]
    print(event["type"], job["id"], job["status"])
    return "", 204
```

```javascript JavaScript theme={"system"}
// npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.VOYAGER_WEBHOOK_SECRET); // whsec_...

app.post("/hooks/voyager", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body.toString("utf8"), {
      "webhook-id": req.header("webhook-id"),
      "webhook-timestamp": req.header("webhook-timestamp"),
      "webhook-signature": req.header("webhook-signature"),
    });
  } catch {
    return res.sendStatus(400);
  }
  console.log(event.type, event.data.id, event.data.status);
  res.sendStatus(204);
});

app.listen(3000);
```

Both libraries reject timestamps more than 5 minutes from your clock, which stops replayed requests.

### Verify by hand

The signature is HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`, and base64-encoded:

```python Python theme={"system"}
import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, body: bytes) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for signature in headers["webhook-signature"].split(" "):
        version, _, value = signature.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

## Respond and retries

Return any `2xx` within 10 seconds, then do the work. Any other response, a redirect, or no answer in 10 seconds counts as a failure, and Voyager retries with backoff for up to **24 hours**. After that the delivery is dropped; the job itself is unaffected.

* Deliveries can arrive more than once and out of order. Deduplicate on `webhook-id`, and check `data.status` rather than assuming order.
* **Revoking a key stops its deliveries**: webhooks for its jobs that haven't been delivered yet are dropped.
* **`GET /v1/jobs/{id}` is authoritative.** If you miss a webhook, or need the final charge, poll.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.