Webhooks
Los webhooks te avisan cuando un job termina, sin polling. Cada entrega es un POST JSON firmado con HMAC SHA-256 sobre el cuerpo exacto que recibes. Si tu endpoint responde 2xx, la entrega se considera exitosa; si no, AlchemyCode reintenta con backoff exponencial.
Crear un webhook
/v1/webhooks201 Created · scope webhooks:write| Campo | Tipo | Descripción |
|---|---|---|
url | stringrequerido | URL HTTPS pública. Se permiten http://localhost y http://127.0.0.1 para desarrollo. |
events | array[string]opcional | Eventos suscritos. Por omisión ["job.succeeded", "job.failed"]. |
description | stringopcional | Nota interna para identificar el endpoint. |
curl -sS -X POST "https://api.434alchemycode.com/v1/webhooks" \
-H "Authorization: Bearer $ALCHEMY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.omnicom.mx/webhooks/alchemy",
"events": ["job.succeeded", "job.failed"],
"description": "ERP Omnicom · producción"
}'
{
"object": "webhook_endpoint",
"id": "d3f8a261-7c04-4e19-b58d-6a2f9c1e0b74",
"url": "https://api.omnicom.mx/webhooks/alchemy",
"description": "ERP Omnicom · producción",
"events": ["job.succeeded", "job.failed"],
"is_active": true,
"last_status": null,
"last_attempt_at": null,
"created_at": "2026-10-31T16:50:12+00:00",
"secret": "whsec_9tR2mK7pQx4vB8nL1sW6yH3dF5gJ0aZc",
"signature_header": "X-Alchemy-Signature",
"signature_scheme": "t=<timestamp>,v1=HMAC_SHA256(t + '.' + body)"
}secret se muestra una sola vez y se guarda cifrado. Guárdalo en tu gestor de secretos antes de cerrar la respuesta; si lo pierdes, desactiva el webhook y crea otro.Eventos
| Evento | Cuándo se envía | Estado |
|---|---|---|
job.succeeded | Un job terminó correctamente. Incluye result resumido y archivos consultables por API. | Activo |
job.failed | Un job falló después de agotar reintentos. Incluye error.code y error.message. | Activo |
job.canceled | Un job fue cancelado antes de ejecutarse. | Declarado |
quota.threshold | La cuenta superó el 80 % de su cuota mensual. | Declarado |
credential.expiring | La e.firma de una empresa vence en 30 días o menos. | Declarado |
Los eventos marcados como declarados forman parte del contrato y se entregarán en la beta; hoy el
sistema emite job.succeeded y job.failed. Suscríbete sólo a los que necesites:
un evento desconocido en events devuelve 422 VALIDATION_ERROR con
valid_events.
Cuerpo del evento
{
"id": "evt_5b1e9c3a7d2f4b68a0c1",
"type": "job.succeeded",
"created_at": "2026-10-31T16:44:13.104221+00:00",
"data": {
"job_id": "4d1f8c0a-6b2e-4c93-8f51-2a7d9e3b6c08",
"service": "sat.constancia",
"status": "succeeded",
"company_id": "9b2f5c1e-7a3d-4f8b-9c21-6d4e8a0b5f17",
"result": {
"rfc": "OMX2503255F9",
"razon_social": "OMNICONMX, S.A. de C.V.",
"estatus_padron": "Activo"
},
"error": null
}
}El cuerpo es un resumen. Para descargar archivos o leer el resultado completo, consulta
GET /v1/jobs/{job_id} con tu API key: el webhook no incluye URLs firmadas.
Firma X-Alchemy-Signature
El encabezado tiene el formato:
X-Alchemy-Signature: t=1793407453,v1=9f2c1a7d4e8b0c6f3a1d5e9b7c2f4a6d8e0b1c3f5a7d9e2b4c6f8a0d1e3b5c7ftes el timestamp Unix en segundos del envío.v1esHMAC_SHA256(secret, t + "." + body)en hexadecimal minúsculas.- El
bodyes el cuerpo crudo recibido, byte por byte. No lo re-serialices antes de verificar.
El algoritmo exacto del lado del servidor es:
mac = HMAC-SHA256(key = secret, message = f"{t}.{body}")
header = f"t={t},v1={mac.hexdigest()}"t mayor a 5 minutos de diferencia con tu reloj: evita reenvíos maliciosos de un payload viejo. La API no impone esa ventana; es tu responsabilidad en el receptor.Verificación en PHP (cURL y hash_hmac, sin dependencias)
<?php
declare(strict_types=1);
// El secreto se guardó al crear el webhook (empieza con whsec_).
$secret = getenv('ALCHEMY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ALCHEMY_SIGNATURE'] ?? '';
// 1) Parsear t=...,v1=...
$parts = [];
foreach (explode(',', $header) as $chunk) {
[$k, $v] = array_pad(explode('=', trim($chunk), 2), 2, '');
$parts[$k] = $v;
}
$timestamp = (int) ($parts['t'] ?? 0);
$signature = (string) ($parts['v1'] ?? '');
if ($timestamp === 0 || $signature === '') {
http_response_code(400);
exit('Firma ausente');
}
// 2) Ventana de tolerancia (5 minutos).
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit('Timestamp fuera de ventana');
}
// 3) HMAC sobre el cuerpo CRUDO.
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Firma inválida');
}
// 4) Firma válida: procesa el evento.
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (($event['type'] ?? '') === 'job.succeeded') {
error_log("Job {$event['data']['job_id']} listo para {$event['data']['service']}");
// Aquí encolas la descarga del job con GET /v1/jobs/{job_id}.
}
http_response_code(200);
header('Content-Type: application/json');
echo '{"received":true}';
Verificación en Node.js (crypto, sin dependencias)
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.ALCHEMY_WEBHOOK_SECRET;
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function verify(rawBody, header) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((chunk) => chunk.trim().split('=').map((v, i) => (i === 0 ? v : v)))
);
const timestamp = Number(parts.t);
const signature = parts.v1 ?? '';
if (!timestamp || !signature) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest();
const received = Buffer.from(signature, 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/alchemy') {
res.writeHead(404).end();
return;
}
const rawBody = await readBody(req);
if (!verify(rawBody, req.headers['x-alchemy-signature'])) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'firma inválida' }));
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
// Responde 2xx rápido; procesa en segundo plano.
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
if (event.type === 'job.succeeded') {
console.log(`Job ${event.data.job_id} listo (${event.data.service})`);
}
}).listen(3000);
crypto.timingSafeEqual y en PHP con hash_equals. Nunca compares firmas con == ni conviertas el cuerpo a objeto antes de calcular el HMAC.Reintentos y timeout
Cada entrega tiene un timeout de 10 segundos y hasta 6 intentos. El backoff es exponencial con techo de una hora:
| Intento | Espera antes del intento | Momento aproximado |
|---|---|---|
| 1 | 0 s | Al terminar el job |
| 2 | 60 s | +1 min |
| 3 | 60 s | +2 min |
| 4 | 120 s | +4 min |
| 5 | 240 s | +8 min |
| 6 | 480 s | +16 min |
- Un 2xx (200–299) marca la entrega como
deliveredy detiene los reintentos. - Cualquier otro código, timeout o error de red reintenta hasta agotar los 6 intentos.
- Si tu endpoint responde 3xx, se trata como fallo: no dependas de redirecciones.
- Los eventos pueden llegar duplicados o desordenados. Usa
id(evento) yX-Alchemy-Deliverypara deduplicar.
X-Alchemy-Signature: t=1793407453,v1=9f2c1a7d4e8b0c6f3a1d5e9b7c2f4a6d8e0b1c3f5a7d9e2b4c6f8a0d1e3b5c7f
X-Alchemy-Event: job.succeeded
X-Alchemy-Delivery: 2871
Content-Type: application/json
User-Agent: AlchemyCode-Webhooks/1.0Probar el endpoint
POST /v1/webhooks/{id}/test encola un evento ping con la misma firma. Responde
202 con la entrega en pending; revisa el resultado en los logs de tu servidor.
curl -sS -X POST "https://api.434alchemycode.com/v1/webhooks/d3f8a261-7c04-4e19-b58d-6a2f9c1e0b74/test" \
-H "Authorization: Bearer $ALCHEMY_API_KEY"Para desactivar un webhook usa DELETE /v1/webhooks/{id} (responde 204). Las
entregas pendientes se marcan como skipped.
Buenas prácticas
- Responde 2xx en menos de 2 segundos y procesa el evento en una cola.
- Verifica la firma sobre el cuerpo crudo, antes de parsear el JSON.
- Valida la ventana de timestamp (5 minutos) y la firma en tiempo constante.
- Deduplica por
iddel evento y porX-Alchemy-Delivery. - Haz el trabajo real consultando el job por API: el webhook es sólo la notificación.
- Monitorea los fallos de entrega y rota el secreto si sospechas filtración (nuevo endpoint, nuevo secreto).
- No publiques la URL del webhook en repositorios públicos ni la uses con parámetros de sesión.