Webhooks
Webhooks send an HTTPS POST to your server when a generation ends, so you do not need to poll.
Adding an endpoint
Add endpoints on your account page, in the Developers section: an https URL on the default port, the events you want and, optionally, one API key whose generations it receives. You get a signing secret starting with whsec_, shown once.
Events
| Event | Sent when |
|---|---|
generation.succeeded | a generation finished and its outputs are ready. |
generation.failed | a generation failed; its coins were refunded. |
generation.canceled | a queued generation was canceled. |
Request
The body is JSON with the event id, its type, createdAt and data.generation: the generation as GET /v1/generations/:id returns it, with fresh download links.
Inker-Signature: the signature:t=the Unix time in seconds andv1=the HMAC-SHA256 in hex.Inker-Event-Id: the event id, the same on every retry. Use it to ignore duplicates.Inker-Event-Type: the event type.
POST /webhooks/inker HTTP/1.1
Content-Type: application/json
Inker-Signature: t=1791200000,v1=5f0c…e81a
Inker-Event-Id: EVENT_ID
Inker-Event-Type: generation.succeeded
{
"id": "EVENT_ID",
"type": "generation.succeeded",
"createdAt": "2026-10-05T12:00:00.000Z",
"data": {
"generation": {
"id": "…",
"modelId": "…",
"status": "succeeded",
"coinsQuoted": 12,
"coinsCharged": 12,
"outputs": [{ "id": "…", "kind": "image", "url": "https://cdn.inker.si/…" }]
}
}
}Verifying the signature
Compute the HMAC-SHA256, in hex, of the timestamp, a dot and the raw request body, with your whole signing secret as the key. Compare it in constant time with every v1 value, and refuse a timestamp more than 300 seconds away from your clock. Always use the exact bytes you received, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* header: the Inker-Signature header ("t=<unix seconds>,v1=<hex>").
* rawBody: the request body exactly as received (string or Buffer), before JSON parsing.
*/
export function verifyInkerSignature(secret, header, rawBody, nowMs = Date.now()) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [name, value] = part.trim().split('=', 2);
if (!value) continue;
if (name === 't' && /^\d{1,12}$/.test(value)) timestamp = Number(value);
if (name === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(nowMs / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((signature) => timingSafeEqual(Buffer.from(signature, 'hex'), expected));
}import express from 'express';
const app = express();
const seen = new Set(); // use your database in production
// express.raw keeps the exact bytes the signature covers.
app.post('/webhooks/inker', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyInkerSignature(
process.env.INKER_WEBHOOK_SECRET,
req.get('Inker-Signature'),
req.body,
);
if (!ok) return res.status(400).send('bad signature');
const eventId = req.get('Inker-Event-Id');
if (seen.has(eventId)) return res.sendStatus(200); // a retry of an event you handled
seen.add(eventId);
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'generation.succeeded') {
// event.data.generation.outputs[].url: download soon, links are short-lived
}
res.sendStatus(200); // answer 2xx within 10 seconds; do slow work afterwards
});import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_inker_signature(secret: str, header: str | None, raw_body: bytes, now: float | None = None) -> bool:
"""header: the Inker-Signature header; raw_body: the request body exactly as received."""
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
name, _, value = part.strip().partition("=")
if name == "t" and re.fullmatch(r"[0-9]{1,12}", value):
timestamp = int(value)
elif name == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if timestamp is None or not signatures:
return False
current = int(time.time() if now is None else now)
if abs(current - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(signature, expected) for signature in signatures)Retries
Answer with any 2xx status within 10 seconds. Otherwise inker tries again after 30 sec, 2 min, 10 min, 30 min, 1 hr, 3 hr, 6 hr, and 12 hr: 9 tries over about a day, then the delivery is marked as failed. A delivery can arrive more than once and out of order, so deduplicate on the event id.
If a secret may have leaked, rotate it from your account page: the old one stops working at once. The recent deliveries of each endpoint, with status codes and errors, are listed there too.