Webhooks
Los webhooks envían un POST HTTPS a tu servidor cuando termina una generación, así no necesitas consultar.
Añadir un endpoint
Añade endpoints en tu página de cuenta, en la sección Desarrolladores: una URL https en el puerto predeterminado, los eventos que quieras y, opcionalmente, una clave de API cuyas generaciones recibe. Obtienes un secreto de firma que empieza por whsec_ y se muestra una sola vez.
Eventos
| Evento | Se envía cuando |
|---|---|
generation.succeeded | una generación terminó y sus salidas están listas. |
generation.failed | una generación falló; sus monedas se reembolsaron. |
generation.canceled | se canceló una generación en cola. |
Solicitud
El cuerpo es JSON con el id del evento, su type, createdAt y data.generation: la generación tal como la devuelve GET /v1/generations/:id, con enlaces de descarga nuevos.
Inker-Signature: la firma:t=la hora Unix en segundos yv1=el HMAC-SHA256 en hexadecimal.Inker-Event-Id: el id del evento, el mismo en cada reintento. Úsalo para ignorar duplicados.Inker-Event-Type: el tipo de evento.
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/…" }]
}
}
}Verificar la firma
Calcula el HMAC-SHA256, en hexadecimal, de la marca de tiempo, un punto y el cuerpo sin procesar de la solicitud, con tu secreto de firma completo como clave. Compáralo en tiempo constante con cada valor v1 y rechaza una marca de tiempo a más de 300 segundos de tu reloj. Usa siempre los bytes exactos que recibiste, antes de analizar el 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)Reintentos
Responde con cualquier estado 2xx en menos de 10 segundos. Si no, inker vuelve a intentarlo después de 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h y 12 h: 9 intentos durante aproximadamente un día; después la entrega se marca como fallida. Una entrega puede llegar más de una vez y fuera de orden, así que elimina duplicados por el id del evento.
Si un secreto puede haberse filtrado, rótalo desde tu página de cuenta: el anterior deja de funcionar al instante. Allí también aparecen las entregas recientes de cada endpoint, con sus códigos de estado y errores.