Docs menu

Webhooks

Get a signed HTTPS callback when a generation or screenshot set finishes or credits run low, and verify it.

View as Markdown

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" }
}
typeSent whendata
generation.succeededa generation succeededthe generation, without its signed URLs: fetch it for fresh ones
generation.faileda generation failed or was canceled (data.status tells which)the generation
screenshot_set.completeda set reached a final status (again after a retry finishes)the screenshot set
credits.lowlive only: a charge took the balance below creditsLowThreshold{ balances, total, threshold }
webhook.testPOST /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:

AttemptAfter the previous one (±10 %)
21 minute
35 minutes
430 minutes
51 hour
63 hours
76 hours
812 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'