Webhooks
A webhook is a request Shootika sends to your server when something happens to a frame, so you do not have to poll for it. Use webhooks to learn that a frame is ready, and keep polling as the fallback.
Webhook endpoints belong to stores that use the web app, and you manage them in the web app. Only admins can see and change them. If Shootika has the API switched off for your store, no deliveries are made until it is back on.
Add an endpoint
Section titled “Add an endpoint”In the web app, open Settings, then Webhooks:
- Choose Add endpoint.
- Enter the endpoint URL. It must be an
httpsURL on port 443, with a host name rather than an IP address, and no username or password. - Pick at least one event.
- Save the signing secret the web app shows you. It starts with
whsec_and is shown once. If you lose it, roll the secret.
A store can have up to 5 endpoints.
Each endpoint has these actions:
| Action | What it does |
|---|---|
| Send test event | Sends a webhook.test event to that endpoint only. A disabled endpoint refuses it (409 webhook_disabled), so re-enable it first. |
| Deliveries | Lists the recent attempts and how each one went. |
| Roll secret | Makes a new secret. The old secret stops working at once, so update your server first or be ready to reject events until you do. |
| Re-enable | Switches a disabled endpoint back on. See When an endpoint is switched off. |
| Delete | Stops the deliveries and removes the endpoint’s history. |
Events
Section titled “Events”| Event | Sent when |
|---|---|
frame.ready |
A frame finished and its image is ready. |
frame.failed |
A frame could not be made. data.frame.error.code says why. |
frame.approved |
A frame was approved. |
frame.unapproved |
An approval was taken down. |
webhook.test |
You chose Send test event. It goes to that endpoint alone, whatever it subscribes to, and data is empty. |
An endpoint receives only the events it subscribed to.
What a delivery looks like
Section titled “What a delivery looks like”Every delivery is a POST with a JSON body, the same envelope for every event:
{ "id": "evt_...", "type": "frame.ready", "created_at": "2026-10-21T09:30:00+00:00", "data": { "frame": { "id": "...", "status": "ready", "image": { "url": "...", "width": 0, "height": 0 } } }}data.frame is the same frame object that GET /v1/frames/{frame_id} returns, shortened here. See
Frames for its fields. For webhook.test, data is {}.
The request carries these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Shootika-Webhooks/1.0 |
Shootika-Signature |
t=<unix seconds>,v1=<hex>. See Verify the signature. |
Answer with any 2xx status within 10 seconds. The body of your answer is ignored. Any other
answer, a timeout or a connection error counts as a failure. A redirect is a failure too: Shootika
does not follow it.
Handle deliveries safely
Section titled “Handle deliveries safely”- Delivery is at least once. The same event can arrive more than once. Store the event
idand ignore one you have already handled. - Events can arrive out of order.
data.frameis the frame’s state at the moment the event was recorded, not its state now. Comparecreated_atbetween events, or fetch the frame withGET /v1/frames/{frame_id}when you need the current state. - Answer quickly. Return
2xxfirst and do the slow work afterwards, so a slow job does not turn into a timeout and a retry.
Verify the signature
Section titled “Verify the signature”Anyone who knows your URL can send it a request. Check the Shootika-Signature header on every
delivery before you trust the body.
The header is t=<unix seconds>,v1=<hex>. v1 is the hex HMAC-SHA256 of the text <t>. followed by
the raw request body, keyed with the whole endpoint secret (the full whsec_... string). To
verify:
- Read
tandv1from the header. - Reject the request when
tis more than 300 seconds from your own clock. That stops a captured request being replayed later. - Compute the HMAC over
<t>.and the body exactly as you received it. Do not parse and re-encode the JSON first: the bytes must be identical. - Compare it with
v1in constant time. A header can carry more than onev1; accept the request when any one of them matches.
A header that cannot be parsed, such as one with no t or no v1, is rejected like a bad signature: return false and do not throw.
Each snippet below ends by checking this known request, which you can use to test your own code:
| Secret | whsec_test_secret |
| Timestamp | 1792540800 |
| Body | {"id":"evt_test","type":"webhook.test","created_at":"2026-10-21T00:00:00+00:00","data":{}} |
| Header | t=1792540800,v1=cfeb41dbf1d4a915eba323134cba7c165ec7e99d5c2e0fd4d2d8814d6ce05bd9 |
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// rawBody: the request body exactly as received (Buffer). header: the Shootika-Signature value.export function verify(secret, rawBody, header, nowSeconds = Math.floor(Date.now() / 1000)) { const parts = String(header ?? '').split(',').map((p) => p.trim().split('=')); const t = Number(parts.find(([k]) => k === 't')?.[1]); const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v); if (!Number.isInteger(t) || signatures.length === 0) return false; if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret) .update(`${t}.`) .update(rawBody) .digest(); return signatures.some((s) => { const given = Buffer.from(s, 'hex'); return given.length === expected.length && timingSafeEqual(given, expected); });}
// Run it against a known request.const secret = 'whsec_test_secret';const t = 1792540800;const body = Buffer.from('{"id":"evt_test","type":"webhook.test","created_at":"2026-10-21T00:00:00+00:00","data":{}}');const header = `t=${t},v1=` + createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex');console.log(header);console.log(verify(secret, body, header, t + 10));console.log(verify(secret, body, header, t + 301));The last two lines print true when the request is 10 seconds old and false when it is 301
seconds old. In a web server, pass the raw body to verify (in Express, express.raw({ type: 'application/json' }) instead of express.json()), answer 400 when it returns false, and
answer 200 once you have stored the event.
Python
Section titled “Python”import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify(secret: str, raw_body: bytes, header: str, now: int | None = None) -> bool: """raw_body: the request body exactly as received. header: the Shootika-Signature value.""" now = int(time.time()) if now is None else now timestamp = None signatures = [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") if key == "t" and value.isascii() and value.isdigit(): timestamp = int(value) elif key == "v1": signatures.append(value) if timestamp is None or not signatures: return False if abs(now - timestamp) > TOLERANCE_SECONDS: return False
expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
# Run it against a known request.secret = "whsec_test_secret"t = 1792540800body = b'{"id":"evt_test","type":"webhook.test","created_at":"2026-10-21T00:00:00+00:00","data":{}}'digest = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()header = f"t={t},v1={digest}"print(header)print(verify(secret, body, header, t + 10))print(verify(secret, body, header, t + 301))The last two lines print True and False in the same way. In a web framework, pass the raw
request bytes (in Flask, request.get_data()), not the parsed JSON.
Retries
Section titled “Retries”Shootika tries each delivery up to 8 times over about 24 hours, waiting longer between tries: 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then 8 hours twice. The first success ends it. An event that is about a day old and still could not be sent is dropped, and the Deliveries list shows it as expired.
The test event is delivered like any other: it is retried, and its failures count toward switching an endpoint off. You can send up to 10 test events an hour for your store. Past that, further test events are refused until the hour is up.
When an endpoint is switched off
Section titled “When an endpoint is switched off”An endpoint that keeps failing is switched off. If deliveries fail for 3 days in a row, with no more than a day between one failure and the next, Shootika disables the endpoint and sends your admins a notification. One success clears the count, and a quiet spell with nothing to send does not add to it.
A disabled endpoint receives nothing and still counts toward the limit of 5. To switch it back on, fix your server and choose Re-enable in Settings, Webhooks. Re-enabling does not resend the events from while it was off.
Where next
Section titled “Where next”- Frames for the frame object and polling.
- Approval for what
frame.approvedandframe.unapprovedfollow. - Errors and limits for the rate limit on API calls.