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, so ready-made libraries can verify them.
Set up your endpoint
- One endpoint per environment. Test and live each have their own
httpsendpoint and their own signing secret, which starts withwhsec_. - 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_urlwith a request, to send that job's events somewhere else.
Events
| Event | When |
|---|---|
generation.succeeded | A generation finished. data is the generation, with output. |
generation.failed | A generation failed and its credit was refunded. data is the generation, and data.error.code says why. |
account.low_balance | A job took your live credits below your low-balance threshold. data holds your account id and live credits, as in GET /v1/account. |
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):
{
"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:
// 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);Then:
- Answer
2xxwithin 10 seconds. Store the event and do the slow work, such as downloading the image, afterwards. - Handle each event once. Skip a
webhook-idyou have already handled.
Retries
- If your endpoint doesn't answer
2xxwithin 10 seconds, we try again for about 24 hours. Redirects count as failures. - Events can arrive twice or out of order. The generation's
statusis the truth, andsucceededandfailednever change. - The generation's
deliveryfield shows how delivery is going. If your endpoint was down, list your 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.
- Join
webhook-id,webhook-timestampand the raw body with dots:Text {webhook-id}.{webhook-timestamp}.{raw body} - Sign that with HMAC-SHA256. The key is the part of your secret after
whsec_, base64-decoded. Base64-encode the result. - Accept the event if the result matches one of the
v1,signatures inwebhook-signature. Compare in constant time. - Reject the event if
webhook-timestampis 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:
{"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.