Webhooks
Les webhooks envoient un POST HTTPS à votre serveur quand une génération se termine : plus besoin d’interroger l’API.
Ajouter un endpoint
Ajoutez des endpoints sur votre page de compte, dans la section Développeurs : une URL https sur le port par défaut, les événements voulus et, si vous le souhaitez, une seule clé d’API dont il reçoit les générations. Vous obtenez un secret de signature commençant par whsec_, affiché une seule fois.
Événements
| Événement | Envoyé quand |
|---|---|
generation.succeeded | une génération est terminée et ses sorties sont prêtes. |
generation.failed | une génération a échoué ; ses pièces ont été remboursées. |
generation.canceled | une génération en file d’attente a été annulée. |
Requête
Le corps est du JSON avec l’id de l’événement, son type, createdAt et data.generation : la génération telle que la renvoie GET /v1/generations/:id, avec de nouveaux liens de téléchargement.
Inker-Signature: la signature :t=l’heure Unix en secondes etv1=le HMAC-SHA256 en hexadécimal.Inker-Event-Id: l’identifiant de l’événement, identique à chaque nouvelle tentative. Utilisez-le pour ignorer les doublons.Inker-Event-Type: le type d’événement.
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/…" }]
}
}
}Vérifier la signature
Calculez le HMAC-SHA256, en hexadécimal, de l’horodatage, d’un point et du corps brut de la requête, avec votre secret de signature complet comme clé. Comparez-le en temps constant avec chaque valeur v1 et refusez un horodatage éloigné de plus de 300 secondes de votre horloge. Utilisez toujours les octets exacts reçus, avant toute analyse JSON.
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)Nouvelles tentatives
Répondez avec un statut 2xx en moins de 10 secondes. Sinon, inker réessaie après 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h et 12 h : 9 tentatives sur environ une journée, puis l’envoi est marqué comme échoué. Un envoi peut arriver plusieurs fois et dans le désordre : dédoublonnez sur l’identifiant de l’événement.
Si un secret a pu fuiter, renouvelez-le depuis votre page de compte : l’ancien cesse de fonctionner immédiatement. Les envois récents de chaque endpoint, avec leurs codes de statut et leurs erreurs, y sont aussi listés.