Webhooks
Get a signed HTTPS callback when a generation or screenshot set finishes or credits run low, and verify it.
Instead of polling, register an HTTPS endpoint and StoreShot POSTs 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.
curl
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}'Node.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.
curl
curl -sS -X POST "https://api.storeshot.art/v1/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $STORESHOT_API_KEY" | jq .Node.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):
{
"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.
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:
Node.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);
});
}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.
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).
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'