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

# Webhooks

> Get notified when a job reaches a terminal state.

Instead of polling, you can have BananaHub POST to a URL of yours when a job reaches a terminal
state (`done` or `failed`). Every delivery is signed so you can confirm it came from us.

## Configure a webhook

Register one or more webhooks in the **web panel**. Each registered webhook has its own URL and its
own **webhook secret** (shown next to the URL). The secret signs every delivery sent to that URL —
copy it into the matching receiver to [verify signatures](#verify-the-signature). Treat it like a
password.

A delivery is always signed with the secret of the **registered webhook whose URL it is sent to**.
That is the secret you verify against — no global or shared secret is involved.

## Choosing which webhook receives a job

You have two options:

<Tabs>
  <Tab title="Default (account webhook)">
    If you register a single webhook and send no `callback_url`, every terminal job is delivered to
    it automatically. (When multiple webhooks are registered and no `callback_url` is given, the
    oldest one is used as the default.)
  </Tab>

  <Tab title="Per request (callback_url)">
    Pass a `callback_url` in the generation request body to route that job's webhook to a specific
    URL. This is how you target different **environments** (production, staging, …) from the same
    account — point each request at the corresponding registered webhook URL.

    ```json theme={null}
    {
      "prompt": "...",
      "callback_url": "https://staging.example.com/webhooks/bananahub"
    }
    ```
  </Tab>
</Tabs>

<Warning>
  The `callback_url` **must exactly match a URL you registered** in the panel. Only then is the
  delivery signed with that webhook's secret and verifiable. A `callback_url` pointing at an
  unregistered URL cannot be validated and should not be used.
</Warning>

## Delivery

When a job finishes, we send a single `POST` with a JSON body and these headers:

| Header         | Description                                                                                  |
| -------------- | -------------------------------------------------------------------------------------------- |
| `Content-Type` | `application/json`                                                                           |
| `X-Signature`  | HMAC-SHA256 signature in the form `sha256=<hex>`. See [verification](#verify-the-signature). |
| `X-Timestamp`  | Unix timestamp (seconds) of when the request was sent.                                       |

A delivery counts as successful when your endpoint responds with any `2xx` status. Anything else
(or a timeout) is retried — see [Retries](#retries).

## Payload

<ResponseField name="job_id" type="string">
  UUID of the job.
</ResponseField>

<ResponseField name="status" type="string">
  Terminal status: `done` or `failed`.
</ResponseField>

<ResponseField name="result" type="object | null">
  Present (non-null) only when `status` is `done`.

  <Expandable title="result">
    <ResponseField name="image_url" type="string">
      Presigned download URL for the generated image. Valid for **24 hours**.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="error" type="string | null">
  Failure reason. Present only when `status` is `failed`, otherwise `null`.
</ResponseField>

<ResponseField name="metadata" type="object | null">
  Echo of the `metadata` you supplied on the generation request. Keys beginning with an underscore
  (`_`) are stripped before sending. `null` if you supplied none.
</ResponseField>

<Warning>
  The webhook payload is similar to [`GET /jobs/{job_id}`](/api-reference/get-job) but **not
  identical**: it omits the `created_at` / `started_at` / `finished_at` timestamps and adds your
  `metadata`. Verify against the fields documented here.
</Warning>

<ResponseExample>
  ```json done theme={null}
  {
    "job_id": "0193a7f2-1c4a-7e0b-9b3a-2f1d8c6e4a55",
    "status": "done",
    "result": {
      "image_url": "https://bananahub.io/results/...?X-Amz-Signature=..."
    },
    "error": null,
    "metadata": { "order_id": "abc123" }
  }
  ```

  ```json failed theme={null}
  {
    "job_id": "0193a7f2-1c4a-7e0b-9b3a-2f1d8c6e4a55",
    "status": "failed",
    "result": null,
    "error": "Provider returned no image",
    "metadata": null
  }
  ```
</ResponseExample>

## Verify the signature

We compute `HMAC-SHA256("{X-Timestamp}.{body}", secret)` and send it as
`X-Signature: sha256=<hex>`, where `body` is the JSON payload serialized with **sorted keys** and no
whitespace.

<Warning>
  Do **not** HMAC the raw bytes you received. Re-serialize the parsed JSON with sorted keys and
  compact separators, exactly as below — otherwise key ordering will not match and verification
  fails.
</Warning>

<Steps>
  <Step title="Re-serialize the body">
    Parse the JSON and dump it with sorted keys and no spaces:
    `json.dumps(payload, sort_keys=True, separators=(",", ":"))`.
  </Step>

  <Step title="Build the signed message">
    Concatenate the timestamp header and the serialized body: `"{X-Timestamp}.{body}"`.
  </Step>

  <Step title="Compute and compare">
    Compute `HMAC-SHA256(message, your_webhook_secret)`, hex-encode it, prefix with `sha256=`, and
    compare to `X-Signature` using a constant-time comparison.
  </Step>

  <Step title="Reject stale deliveries">
    Reject the request if `|now - X-Timestamp| > 300` seconds to guard against replays.
  </Step>
</Steps>

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib, json, time

  WEBHOOK_SECRET = "your_secret_here"  # from the web panel

  def verify_webhook(raw_body: bytes, x_signature: str, x_timestamp: str) -> bool:
      if abs(time.time() - int(x_timestamp)) > 300:
          return False
      payload = json.loads(raw_body)
      body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
      message = f"{x_timestamp}.{body}"
      expected = "sha256=" + hmac.new(
          WEBHOOK_SECRET.encode(),
          message.encode(),
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, x_signature)
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");
  const WEBHOOK_SECRET = "your_secret_here"; // from the web panel

  function verifyWebhook(rawBody, xSignature, xTimestamp) {
    if (Math.abs(Date.now() / 1000 - Number(xTimestamp)) > 300) return false;

    const payload = JSON.parse(rawBody);
    const body = JSON.stringify(
      Object.fromEntries(Object.entries(payload).sort()),
      null,
      0
    );
    const message = `${xTimestamp}.${body}`;
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", WEBHOOK_SECRET).update(message).digest("hex");

    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(xSignature));
  }
  ```
</CodeGroup>

<Note>
  The Node.js example sorts only top-level keys. If your payload contains nested objects, sort
  recursively to match our serialization.
</Note>

## Retries

If a delivery does not return `2xx`, we retry up to **6 times** with increasing delays:

| Attempt   | Delay after previous |
| --------- | -------------------- |
| 1st retry | 1 minute             |
| 2nd retry | 2 minutes            |
| 3rd retry | 5 minutes            |
| 4th retry | 10 minutes           |
| 5th retry | 15 minutes           |
| 6th retry | 20 minutes           |

That is 7 delivery attempts over roughly 53 minutes before the webhook is marked failed. Each
attempt is re-signed with a fresh `X-Timestamp`, so validate every delivery independently and make
your handler idempotent on `job_id`.
