Loading your account

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

EventSent when
generation.succeededa generation finished and its outputs are ready.
generation.faileda generation failed; its coins were refunded.
generation.canceleda 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 and v1= 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.
HTTP
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.

Node.js
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));
}
Node.js · Express
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
});
Python
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.