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

# Task webhooks

> Add a webhook URL when submitting a task and receive the result by POST — no polling.

Add a `webhook` field when you submit an asynchronous task. When the task
finishes (succeeds or fails), SnapGen POSTs the result to your URL. No
registration, no extra credentials — just your API key.

## Quick start

```bash theme={null}
curl https://api.snapgen.org/v1/videos \
  -H "Authorization: Bearer $SNAPGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemini-omni-t2v",
    "prompt": "A paper airplane glides through a sunlit library",
    "seconds": "10",
    "webhook": "https://your-server.com/snapgen/callback",
    "webhook_secret": "your-random-secret"
  }'
```

`webhook_secret` is optional — it makes deliveries
[HMAC-signed](#verifying-authenticity) so you can verify them.

We POST to exactly the URL you provide. Webhooks fire only on **final states**
(`completed` / `failed`) — never while the task is still running.

## What you receive

The POST body is **identical to the [Get task status](/api-reference/task-status)
response** — one parser handles both:

```json theme={null}
{
  "id": "task_01HXYZ",
  "object": "task",
  "status": "completed",
  "model": "gemini-omni-t2v",
  "progress": 100,
  "created": 1784324432,
  "completed": 1784324660,
  "cost": 0.25,
  "result": {
    "videos": [
      {
        "url": ["https://storage.example.com/videos/output.mp4"],
        "expires_at": 1785534260
      }
    ]
  },
  "error": null
}
```

Failed tasks arrive with `"status": "failed"`, `"result": null`, and
`error.message` — they are automatically refunded.

## Retries and deduplication

* Respond with any `2xx` within 10 seconds. Acknowledge first, process
  asynchronously.
* No `2xx` (timeout or `5xx`): we retry after **10s, 30s, 60s, then 5m, 30m,
  and 2h** — about three hours in total, so a deploy window or short outage
  on your side doesn't lose the notification.
* A `4xx` response means your address rejected the request — we stop
  immediately, no retries.
* In rare cases you may receive the same notification twice. Deduplicate on
  `id` + `status`.
* Webhooks are an optimization, not the source of truth: if all retries are
  exhausted, [`GET /v1/tasks/{id}`](/api-reference/task-status) still has the
  result. Keep a reconciliation poll for tasks you haven't heard back about.

## Webhook URL requirements

| Requirement        | Detail                                                                                        |
| ------------------ | --------------------------------------------------------------------------------------------- |
| Publicly reachable | Internal/private addresses (`127.0.0.1`, `10.x`, `192.168.x`, …) are rejected at submit time. |
| Protocol           | `http` or `https` (use `https` in production).                                                |
| Not our domain     | URLs pointing at `snapgen.org` are rejected.                                                  |

An invalid `webhook` value fails the submission with an `invalid_request`
error — fix the URL and resubmit.

<Note>
  Developing locally? Private addresses can't receive webhooks — expose your
  handler with a tunnel (for example `cloudflared tunnel` or `ngrok http
      3000`) and use the tunnel URL, or just poll
  [`GET /v1/tasks/{id}`](/api-reference/task-status) during development.
</Note>

## Verifying authenticity

Two options, from simplest to strongest:

**Option 1 — re-fetch (no secret needed).** Treat the callback as a
notification and confirm the result with the source of truth: fetch
[`GET /v1/tasks/{id}`](/api-reference/task-status) using your own API key
before acting on it. A forged callback cannot survive that check.

**Option 2 — signed webhooks.** Pass a `webhook_secret` (8–128 characters,
you choose it) alongside `webhook` when submitting:

```json theme={null}
{ "webhook": "https://your-server.com/callback", "webhook_secret": "your-random-secret" }
```

Deliveries for that task then carry an
`X-Shim-Signature: t=<unix seconds>,v1=<hex>` header, where
`v1 = HMAC-SHA256(webhook_secret, "<t>.<raw request body>")`. Compute over the
raw body bytes and reject timestamps older than five minutes:

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require("node:crypto");

  function verifyWebhook(secret, rawBody, header, toleranceSec = 300) {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=", 2)),
    );
    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${parts.t}.${rawBody}`)
      .digest("hex");
    const given = Buffer.from(parts.v1 ?? "");
    return (
      given.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), given)
    );
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_webhook(secret, raw_body, signature_header, tolerance_sec=300):
      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      if abs(time.time() - int(parts["t"])) > tolerance_sec:
          return False
      expected = hmac.new(
          secret.encode(),
          f"{parts['t']}.{raw_body}".encode(),
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```
</CodeGroup>

## Minimal receiver

<CodeGroup>
  ```javascript Node.js theme={null}
  const http = require("node:http");

  http.createServer((req, res) => {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      res.writeHead(200).end("ok"); // acknowledge first
      const task = JSON.parse(body);
      console.log("task finished:", task.id, task.status);
      // dedupe on task.id + task.status, then confirm via GET /v1/tasks/{id}
    });
  }).listen(3000);
  ```

  ```python Python theme={null}
  from http.server import BaseHTTPRequestHandler, HTTPServer
  import json

  class Handler(BaseHTTPRequestHandler):
      def do_POST(self):
          length = int(self.headers.get("Content-Length") or 0)
          task = json.loads(self.rfile.read(length))
          self.send_response(200)
          self.end_headers()  # acknowledge first
          print("task finished:", task["id"], task["status"])
          # dedupe on id + status, then confirm via GET /v1/tasks/{id}

  HTTPServer(("0.0.0.0", 3000), Handler).serve_forever()
  ```
</CodeGroup>

## FAQ

<AccordionGroup>
  <Accordion title="I submitted with a webhook but received nothing">
    Check, in order: (1) has the task actually finished? Fetch
    `GET /v1/tasks/{id}` — webhooks only fire on `completed`/`failed`;
    (2) is your URL publicly reachable from the internet; (3) does your
    endpoint return `2xx` within 10 seconds — a `4xx` stops delivery
    permanently.
  </Accordion>

  <Accordion title="Why is result.videos[].url an array?">
    Some models return multiple files per task. Handle it as an array even
    when it contains one URL.
  </Accordion>

  <Accordion title="How long do result links work?">
    Until `expires_at` (media is retained for 14 days). Download the files to
    your own storage promptly.
  </Accordion>

  <Accordion title="Do webhooks replace polling?">
    Polling stays fully supported. Webhooks are an optimization — if a
    delivery is missed, `GET /v1/tasks/{id}` always has the latest state.
    For anything critical, keep a reconciliation poll as the fallback.
  </Accordion>

  <Accordion title="Can I receive webhooks on localhost?">
    No — private addresses are rejected for security. Use a tunnel
    (`cloudflared`, `ngrok`) during development, or poll instead.
  </Accordion>
</AccordionGroup>
