# Webhooks

> HTML version: https://storeshot.art/docs/webhooks/

Instead of polling, register an HTTPS endpoint and StoreShot `POST`s a signed event to it when a
generation finishes, a screenshot set finishes or your live credit balance runs low. Webhooks need
the `webhooks:write` scope (and `webhooks:read` to read them), which new API keys do **not** have
by default: create the key with them in the console.

The Node.js and Python samples on this page continue from the first one (`api`, `headers`,
`webhook`).

## Create a webhook

The URL must be `https://` on port 443 and resolve only to public addresses (private, loopback and
link-local hosts are `422 WEBHOOK_URL_REJECTED`). `events` takes 1–4 of `generation.succeeded`,
`generation.failed` (failed or canceled), `screenshot_set.completed` and `credits.low` (live only,
when a charge takes the balance below `creditsLowThreshold`). A key creates webhooks of its own
environment: a test webhook receives the events of test generations and sets only. The response
carries the signing secret once, in `secret.value`; store it with your receiver.

<!-- tabs -->
```sh
WEBHOOK=$(curl -sS https://api.storeshot.art/v1/webhooks \
  -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/webhooks/storeshot",
       "events":["generation.succeeded","generation.failed","screenshot_set.completed"],
       "description":"Release pipeline"}')
WEBHOOK_ID=$(echo "$WEBHOOK" | jq -r .id)
STORESHOT_WEBHOOK_SECRET=$(echo "$WEBHOOK" | jq -r .secret.value)   # store it with your receiver
echo "$WEBHOOK" | jq '{id, url, events, environment, secret: .secret.hint}'
```

```js
const api = 'https://api.storeshot.art/v1';
const headers = { Authorization: `Bearer ${process.env.STORESHOT_API_KEY}` };

const webhook = await (
  await fetch(`${api}/webhooks`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: 'https://example.com/webhooks/storeshot',
      events: ['generation.succeeded', 'generation.failed', 'screenshot_set.completed'],
      description: 'Release pipeline',
    }),
  })
).json();
const webhookSecret = webhook.secret.value; // shown once: store it with your receiver
console.log(webhook.id, webhook.url, webhook.secret.hint);
```

```python
import os, requests

api = "https://api.storeshot.art/v1"
headers = {"Authorization": f"Bearer {os.environ['STORESHOT_API_KEY']}"}

webhook = requests.post(
    f"{api}/webhooks",
    headers=headers,
    json={
        "url": "https://example.com/webhooks/storeshot",
        "events": ["generation.succeeded", "generation.failed", "screenshot_set.completed"],
        "description": "Release pipeline",
    },
).json()
webhook_secret = webhook["secret"]["value"]  # shown once: store it with your receiver
print(webhook["id"], webhook["url"], webhook["secret"]["hint"])
```

After creation the URL is only shown masked (scheme, host and the last characters). An account
has at most 10 webhooks per environment.

## Send a test event

`POST /webhooks/{id}/test` sends a `webhook.test` event whatever the webhook's `events` (at most 10
per webhook per hour). Use it to check your receiver and its signature check end to end.

<!-- tabs -->
```sh
curl -sS -X POST "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $STORESHOT_API_KEY" | jq .
```

```js
const sent = await (
  await fetch(`${api}/webhooks/${webhook.id}/test`, { method: 'POST', headers })
).json();
console.log(sent.eventId, sent.deliveryId);
```

```python
sent = requests.post(f"{api}/webhooks/{webhook['id']}/test", headers=headers).json()
print(sent["eventId"], sent["deliveryId"])
```

## Events

Every delivery is a `POST` with a JSON body like this one (the exact bytes are signed; the body
also carries `apiVersion`, the contract version it was built with):

```json
{
  "id": "evt_3f2a9c1d0b8e4f6a9d2c7b1e0a5f4c3d",
  "type": "generation.succeeded",
  "created": "2026-10-22T10:00:24.000Z",
  "environment": "test",
  "data": { "id": "0b8e7a52-…", "status": "succeeded" }
}
```

| `type` | Sent when | `data` |
|---|---|---|
| `generation.succeeded` | a generation succeeded | the generation, without its signed URLs: fetch it for fresh ones |
| `generation.failed` | a generation failed or was canceled (`data.status` tells which) | the generation |
| `screenshot_set.completed` | a set reached a final status (again after a retry finishes) | the screenshot set |
| `credits.low` | live only: a charge took the balance below `creditsLowThreshold` | `{ balances, total, threshold }` |
| `webhook.test` | `POST /webhooks/{id}/test` | `{ webhookId, message }` |

The images of a set are generations too, so they also send `generation.*` events. Headers:
`Storeshot-Event-Id` (the same on every attempt), `Storeshot-Event-Type`, `Storeshot-Delivery-Id`,
`Storeshot-Delivery-Attempt` and `Storeshot-Signature`. The full payload schemas are in the
[webhooks reference](/docs/api/webhooks/).

## Verify the signature

`Storeshot-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 with your webhook
secret over `<t>.<raw request body>`. During a secret rotation a second `v1` (the previous secret)
follows, so accept any matching `v1`. Verify the **raw bytes** before parsing the JSON, compare in
constant time and reject a `t` more than 5 minutes from now. These two functions are the ones
StoreShot's own tests run:

<!-- tabs -->
<!-- doctest: covered-by=apps/server/__tests__/docs/webhookSnippets.test.ts -->
```js
// docs/api/snippets/verify-webhook.mjs (Node ≥ 18, imported verbatim by the tests)
// rawBody: the exact request bytes (Buffer or string), before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyStoreshotSignature(rawBody, header, secret, toleranceSec = 300, now = Date.now() / 1000) {
  const items = header.split(',').map((p) => p.trim().split('='));
  const ts = items.filter(([k]) => k === 't').map(([, v]) => v);
  if (ts.length !== 1 || !/^\d+$/.test(ts[0]) || Math.abs(now - Number(ts[0])) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${ts[0]}.`).update(rawBody).digest();
  return items.some(([k, v]) => {
    if (k !== 'v1') return false;
    const got = Buffer.from(v ?? '', 'hex');
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

<!-- doctest: covered-by=apps/server/__tests__/docs/webhookSnippets.test.ts -->
```python
# docs/api/snippets/verify_webhook.py (Python ≥ 3.8, run by the tests via python3)
import hashlib, hmac, time
def verify_storeshot_signature(raw_body: bytes, header: str, secret: str, tolerance=300, now=None) -> bool:
    items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    ts = [v for k, v in items if k == "t"]
    if len(ts) != 1 or not ts[0].isdigit() or abs((now or time.time()) - int(ts[0])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), ts[0].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, v) for k, v in items if k == "v1")
```

Answer any `2xx` within 10 seconds, then do the work. Delivery is at least once and unordered:
deduplicate on `Storeshot-Event-Id`, and fetch the resource when order matters.

## Deliveries and retries

A delivery that does not get a `2xx` within 10 seconds (another status, a timeout, a connection or
TLS error, or a redirect, which is never followed) is retried:

| Attempt | After the previous one (±10 %) |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7 | 6 hours |
| 8 | 12 hours |

After the 8th failed attempt (about 23 hours) the delivery is `exhausted`. A destination that
became private fails at once without retries. `GET /webhooks/{id}/deliveries` shows every delivery
(`pending`, `succeeded`, `exhausted`, `failed`, `canceled`) with each attempt's time, latency,
status code, error class and the first 256 characters of your answer, for 30 days.

```sh
curl -sS "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/deliveries?limit=10" \
  -H "Authorization: Bearer $STORESHOT_API_KEY" \
  | jq -r '.data[] | "\(.eventType) \(.status) attempts=\(.attempts)"'
```

## Rotate the secret, disable, delete

`POST /webhooks/{id}/rotate-secret` returns a new secret once. For `gracePeriodSeconds` (default
86400, at most 604800; `0` drops the old secret at once) every delivery is signed with both, so you
can deploy the new secret without losing events. `PATCH` changes the URL, events, description or
`enabled`; a disabled webhook receives nothing. `DELETE` removes the webhook with its pending
deliveries and their log (`204`).

```sh
curl -sS -X POST "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/rotate-secret" \
  -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"gracePeriodSeconds":3600}' | jq '{hint: .secret.hint, previousExpiresAt: .secret.previousExpiresAt}'

curl -sS -X PATCH "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $STORESHOT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"enabled":false}' | jq '{id, enabled}'

curl -sS -X DELETE "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $STORESHOT_API_KEY" -o /dev/null -w '%{http_code}\n'
```
