AlchemyCode API docs

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

POST/v1/webhooks201 Created · scope webhooks:write
CampoTipoDescripción
urlstringrequeridoURL HTTPS pública. Se permiten http://localhost y http://127.0.0.1 para desarrollo.
eventsarray[string]opcionalEventos suscritos. Por omisión ["job.succeeded", "job.failed"].
descriptionstringopcionalNota interna para identificar el endpoint.
Shell
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"
  }'
JSON
{
  "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)"
}
El 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

EventoCuándo se envíaEstado
job.succeededUn job terminó correctamente. Incluye result resumido y archivos consultables por API.Activo
job.failedUn job falló después de agotar reintentos. Incluye error.code y error.message.Activo
job.canceledUn job fue cancelado antes de ejecutarse.Declarado
quota.thresholdLa cuenta superó el 80 % de su cuota mensual.Declarado
credential.expiringLa 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

JSON
{
  "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:

HTTP
X-Alchemy-Signature: t=1793407453,v1=9f2c1a7d4e8b0c6f3a1d5e9b7c2f4a6d8e0b1c3f5a7d9e2b4c6f8a0d1e3b5c7f
  • t es el timestamp Unix en segundos del envío.
  • v1 es HMAC_SHA256(secret, t + "." + body) en hexadecimal minúsculas.
  • El body es el cuerpo crudo recibido, byte por byte. No lo re-serialices antes de verificar.

El algoritmo exacto del lado del servidor es:

Texto
mac = HMAC-SHA256(key = secret, message = f"{t}.{body}")
header = f"t={t},v1={mac.hexdigest()}"
Rechaza entregas con 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
<?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)

Node.js
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);
En Node.js compara con 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:

IntentoEspera antes del intentoMomento aproximado
10 sAl terminar el job
260 s+1 min
360 s+2 min
4120 s+4 min
5240 s+8 min
6480 s+16 min
  • Un 2xx (200–299) marca la entrega como delivered y 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) y X-Alchemy-Delivery para deduplicar.
HTTP
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.0

Probar 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.

Shell
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 id del evento y por X-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.

Continúa