Contrato HTTP
Referencia del webhook de Kapture
Esta pagina documenta el contrato org-level: headers, algoritmo de firma, payloads publicos, tiempos de respuesta esperados y ejemplos de verificacion en varios stacks.
Cabeceras
Headers enviados por el dispatcher org-level
| Header | Ejemplo | Uso |
|---|---|---|
Content-Type |
application/json |
Body JSON serializado como string. |
User-Agent |
Kapture-Webhooks/1.0 |
Identifica el dispatcher publico actual. |
X-Kapture-Signature |
sha256=<digest> |
Firma HMAC SHA-256 del body crudo. |
X-Kapture-Event |
record.created |
Nombre del evento emitido. |
X-Kapture-Timestamp |
2026-04-30T15:00:00.000Z |
Timestamp del envio HTTP. |
Contrato
Lo que el receptor debe cumplir
El dispatcher org-level espera una respuesta en menos de 5 segundos.
Si el evento ya quedo duradero en el receptor, la respuesta debe ser `200` o `202`.
No se debe regenerar JSON ni cambiar el orden antes de validar la firma.
Usa `event + id` como llave minima para evitar duplicados aguas abajo.
Evento
Schema de `record.created`
| Campo | Tipo | Descripcion |
|---|---|---|
event |
string | Siempre `record.created`. |
id |
string | ID del registro. |
parent_id |
string | ID de la captura a la que pertenece. |
timestamp |
string ISO 8601 | `transactionDate` del registro o fecha actual como fallback. |
type |
string | `templateId` del registro o `UNKNOWN`. |
vendor |
string opcional | Nombre del vendor si existe. |
currency |
string opcional | Moneda detectada en el registro. |
data |
objeto | Payload de negocio aplanado desde `record.data`. |
Ejemplo
{
"event": "record.created",
"id": "rec_demo_001",
"parent_id": "cap_demo_001",
"timestamp": "2026-04-30T15:00:00.000Z",
"type": "INVOICE",
"vendor": "ACME S.A.S.",
"currency": "COP",
"data": {
"invoice_number": "FAC-001",
"total": 120000
}
}
Evento
Schema de `expedient.completed`
| Campo | Tipo | Descripcion |
|---|---|---|
event |
string | Siempre `expedient.completed`. |
id |
string | ID del expediente. |
timestamp |
string ISO 8601 | Fecha de construccion del payload. |
type |
string | `templateId` del expediente o `UNKNOWN`. |
data |
objeto | Documento del expediente ya sanitizado, sin `imageBytes`. |
Ejemplo
{
"event": "expedient.completed",
"id": "exp_demo_001",
"timestamp": "2026-04-30T15:00:00.000Z",
"type": "INVOICE",
"data": {
"id": "exp_demo_001",
"userId": "usr_demo_001",
"templateId": "INVOICE",
"sessionName": "Lote de prueba",
"status": "completed"
}
}
Firma
Verificacion de HMAC SHA-256
El digest se calcula sobre el string JSON exacto. La formula conceptual es: `sha256 = HMAC(secret, rawBody)`.
Node.js + Express
import crypto from 'crypto';
import express from 'express';
const app = express();
app.post('/kapture/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signatureHeader = req.header('x-kapture-signature');
if (!signatureHeader) return res.status(400).send('missing signature');
const [algorithm, digest] = signatureHeader.split('=');
if (algorithm !== 'sha256' || !digest) {
return res.status(400).send('invalid signature format');
}
const expectedDigest = crypto
.createHmac('sha256', process.env.KAPTURE_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(digest, 'hex'),
Buffer.from(expectedDigest, 'hex')
);
return valid ? res.status(202).send('accepted') : res.status(401).send('invalid');
});
Next.js App Router
import crypto from 'crypto';
export async function POST(request) {
const rawText = await request.text();
const signatureHeader = request.headers.get('x-kapture-signature');
if (!signatureHeader) {
return new Response('missing signature', { status: 400 });
}
const [algorithm, digest] = signatureHeader.split('=');
const expectedDigest = crypto
.createHmac('sha256', process.env.KAPTURE_WEBHOOK_SECRET)
.update(rawText)
.digest('hex');
const valid =
algorithm === 'sha256' &&
digest &&
crypto.timingSafeEqual(Buffer.from(digest, 'hex'), Buffer.from(expectedDigest, 'hex'));
if (!valid) {
return new Response('invalid signature', { status: 401 });
}
const payload = JSON.parse(rawText);
console.log(payload);
return new Response('accepted', { status: 202 });
}
Python + FastAPI
import hashlib
import hmac
from fastapi import FastAPI, Header, Request, HTTPException
app = FastAPI()
SECRET = "replace-me"
@app.post("/kapture/webhook")
async def receive_webhook(
request: Request,
x_kapture_signature: str | None = Header(default=None)
):
if not x_kapture_signature:
raise HTTPException(status_code=400, detail="missing signature")
raw_body = await request.body()
algo, digest = x_kapture_signature.split("=", 1)
expected = hmac.new(
SECRET.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
if algo != "sha256" or not hmac.compare_digest(digest, expected):
raise HTTPException(status_code=401, detail="invalid signature")
return {"status": "accepted"}
Entrega
Semantica de entrega que importa
El dispatcher directo usa `axios.post()` y considera error cualquier no-2xx o timeout. El timeout actual es 5 segundos.
La via legacy distingue 4xx y 5xx: 4xx se descartan, 5xx o fallos de red devuelven 500 para que la cola pueda reintentar.
El webhook org-level envia el payload publico directamente. El worker legacy envuelve los datos en un objeto con `eventId`, `eventType`, `timestamp` y `data`.
La forma recomendada es persistir `event:id` y confirmar rapido con `2xx`.