# Webhooks

Source: https://docs.foodshot.ai/guides/webhooks

> Get a signed event on your server when a generation finishes.

We send a webhook to your server when a generation finishes, so you don't have to poll. Webhooks follow [Standard Webhooks](https://www.standardwebhooks.com/), so ready-made libraries can verify them.

## Set up your endpoint

* One endpoint per environment. Test and live each have their own `https` endpoint and their own signing secret, which starts with `whsec_`.
* You set them in the dashboard, which opens with your invitation. There you can also send a test event.
* Test jobs send real, signed events to your test endpoint. Test keys can also send `webhook_url` with a request, to send that job's events somewhere else.

## Events

| Event                                                                    | When                                                                                                                                                                      |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`generation.succeeded`](https://docs.foodshot.ai/reference/webhook-events#generation-succeeded) | A generation finished. `data` is the generation, with `output`.                                                                                                           |
| [`generation.failed`](https://docs.foodshot.ai/reference/webhook-events#generation-failed)       | A generation failed and its credit was refunded. `data` is the generation, and `data.error.code` says why.                                                                |
| [`account.low_balance`](https://docs.foodshot.ai/reference/webhook-events#account-low-balance)   | A job took your live credits below your [low-balance threshold](https://docs.foodshot.ai/policies/credits#low-balance). `data` holds your account id and live `credits`, as in `GET /v1/account`. |
| [`webhook.test`](https://docs.foodshot.ai/reference/webhook-events#webhook-test)                 | You sent a test event from the dashboard. `data` holds a test message.                                                                                                    |

A `generation.succeeded` event (shortened here; `data` is the whole generation):

```json
{
  "id": "evt_4Kq9xT2mVb7Zp1Ls8Hc3Rw",
  "type": "generation.succeeded",
  "environment": "live",
  "created_at": "2026-10-18T10:42:42.901Z",
  "data": {
    "id": "gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw",
    "object": "generation",
    "status": "succeeded",
    "metadata": { "dish_id": "d_456" },
    "output": {
      "url": "https://api.foodshot.ai/v1/files/gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw/image.jpg?exp=1792406563&sig=8fwqDeaHcFOU4b68TzesgCYKN8NG02QP3yMTmmVM9Pc",
      "content_type": "image/jpeg"
    }
  }
}
```

We may add event types. Answer `2xx` to types you don't know, and ignore them.

## Verify every webhook

Check the signature before you trust an event. The easiest way is a Standard Webhooks library, which takes your `whsec_` secret as it is:

**Node**

```js
// npm install standardwebhooks
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.FOODSHOT_WEBHOOK_SECRET);
// In your handler. rawBody: the exact bytes we sent. Throws if the signature or the timestamp is wrong.
const event = wh.verify(rawBody, req.headers);
```

**Python**

```python
# pip install standardwebhooks
import os

from standardwebhooks import Webhook

wh = Webhook(os.environ["FOODSHOT_WEBHOOK_SECRET"])
# In a Flask handler: the exact bytes we sent. Raises if the signature or the timestamp is wrong.
event = wh.verify(request.get_data(), request.headers)
```

Then:

1. Answer `2xx` within 10 seconds. Store the event and do the slow work, such as downloading the image, afterwards.
2. Handle each event once. Skip a `webhook-id` you have already handled.

## Retries

* If your endpoint doesn't answer `2xx` within 10 seconds, we try again for about 24 hours. Redirects count as failures.
* Events can arrive twice or out of order. The generation's `status` is the truth, and `succeeded` and `failed` never change.
* The generation's `delivery` field shows how delivery is going. If your endpoint was down, [list your generations](https://docs.foodshot.ai/reference/list-generations) to catch up.

## Rotate your secret

Rotate the secret in the dashboard whenever you need to. For 24 hours after a rotation, each event is signed with both the old and the new secret, so you can switch without losing events.

## Verify without a library

**Headers.** `webhook-id` is the event id, the same on every retry. `webhook-timestamp` is when the attempt was signed, in Unix seconds. `webhook-signature` holds one or more signatures, such as `v1,pEOl...`, separated by spaces.

1. Join `webhook-id`, `webhook-timestamp` and the raw body with dots:
   ```text
   {webhook-id}.{webhook-timestamp}.{raw body}
   ```
2. Sign that with HMAC-SHA256. The key is the part of your secret after `whsec_`, base64-decoded. Base64-encode the result.
3. Accept the event if the result matches one of the `v1,` signatures in `webhook-signature`. Compare in constant time.
4. Reject the event if `webhook-timestamp` is more than 5 minutes from your clock.

**Test vector.** Your verifier must accept this request. Its timestamp is in the past, so use `1792504800` as the current time when you test with it.

| Field               | Value                                                             |
| ------------------- | ----------------------------------------------------------------- |
| Secret              | `whsec_KBxnq+TLsMLw5CC0ynnKsGVMFsqFVFn4UNbIM5cfXuw=` (an example) |
| `webhook-id`        | `evt_7Hn2Wq5Rk8Ys1Df4Gb6Jt3Lm`                                    |
| `webhook-timestamp` | `1792504800`                                                      |
| `webhook-signature` | `v1,pEOlmetTrn5MsK0vNECj0TycJro/N/il4L4HEaOM08Q=`                 |

The raw body, on one line with no line break at the end:

```json
{"id":"evt_7Hn2Wq5Rk8Ys1Df4Gb6Jt3Lm","type":"webhook.test","environment":"test","created_at":"2026-10-20T14:00:00.000Z","data":{"object":"webhook_test","message":"This is a test event from FoodShot."}}
```

**IP addresses.** Our webhooks come from addresses that change. Verify signatures instead of allowlisting IPs.
