Ir al contenido

Webhooks

Un webhook es una petición POST que Emitay hace a tu servidor cuando algo ocurre: SUNAT aceptó una factura, rechazó una boleta, un correo no se pudo enviar. Es la forma de enterarse que usa un backend, porque llega aunque SUNAT tarde y aunque tu proceso se haya reiniciado.

type Cuándo data.object
document.accepted SUNAT aceptó el comprobante El comprobante
document.observed SUNAT lo aceptó con observaciones El comprobante
document.rejected SUNAT lo rechazó, o venció su plazo sin respuesta El comprobante
document.voided SUNAT aceptó la anulación El comprobante
document.void_failed SUNAT rechazó la anulación El comprobante
email.sent El servidor de correo aceptó el mensaje El correo
email.failed El correo no se pudo enviar El correo

El cuerpo es el evento, con el recurso completo tal como quedó. No hace falta consultarlo después:

{
"id": "evt_…",
"object": "event",
"type": "document.accepted",
"created_at": "2026-10-08T22:32:56.000Z",
"livemode": false,
"data": {
"object": { "id": "doc_…", "object": "document", "series": "F001", "number": 2, "status": "accepted" }
}
}

No necesitas un túnel ni una URL pública. emitay listen sigue los eventos de tu cuenta y los reenvía a tu servidor local, firmados igual que una entrega real:

Ventana de terminal
npx emitay listen --forward-to http://localhost:3000/webhooks/emitay

Al arrancar muestra el secreto con que firma; ponlo en EMITAY_WEBHOOK_SECRET y tu receptor ya puede verificar. El recorrido completo está en el inicio rápido.

Cualquiera puede hacer un POST a tu URL. La firma prueba que la entrega es de Emitay y que nadie cambió el cuerpo. Verifica siempre, y con el cuerpo tal como llegó: lo firmado son sus bytes, no el JSON vuelto a escribir.

import { EmitayWebhookError, verifyWebhook } from "emitay";
// Un route handler de Next.js; con Express, usa express.raw() en esta ruta.
export async function POST(request: Request): Promise<Response> {
try {
const event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.EMITAY_WEBHOOK_SECRET!,
});
switch (event.type) {
case "document.accepted":
console.log(event.data.object.links.pdf); // event.data.object es un Document
break;
case "document.rejected":
console.error(event.data.object.sunat?.description);
break;
case "email.failed":
console.error(event.data.object.last_error); // aquí es un Email
break;
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof EmitayWebhookError) return new Response(error.message, { status: 400 });
throw error;
}
}

El type del evento dice qué trae: dentro de cada case, event.data.object ya tiene su tipo.

Las entregas siguen Standard Webhooks, así que cualquier librería de ese estándar las verifica. A mano:

Cabecera Contenido
webhook-id El id del evento (evt_…). Es el mismo en cada reintento
webhook-timestamp La hora del intento, en segundos Unix
webhook-signature v1,<firma en base64>
  1. Une webhook-id, webhook-timestamp y el cuerpo con puntos: id.timestamp.cuerpo.
  2. Calcula su HMAC-SHA256. La clave son los bytes que resultan de decodificar en base64 el secreto sin su prefijo whsec_.
  3. Compara el resultado, en base64, con lo que sigue a v1,, en tiempo constante.
  4. Rechaza la entrega si webhook-timestamp difiere de tu reloj en más de cinco minutos.
  • Responde 2xx en menos de 10 segundos. Cualquier otra cosa —otro estado, una redirección, ninguna respuesta— cuenta como intento fallido.
  • Haz el trabajo pesado después de responder: guarda el evento, contesta 204 y procésalo en segundo plano.
  • Un mismo evento puede llegar más de una vez. Guarda su id (el webhook-id) y sáltate el que ya procesaste.
  • No cuentes con el orden. Dos eventos pueden llegar en otro orden del que ocurrieron; cada uno trae el recurso completo, con su updated_at.

Si tu servidor no responde 2xx, Emitay reintenta a los 5 segundos, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas más: ocho intentos en total, en algo más de un día. Después la entrega queda failed.

Cada intento queda registrado con lo que respondió tu servidor:

const entregas = await emitay.webhookEndpoints.deliveries.list("we_…", { status: "failed" });
for (const entrega of entregas.data) {
console.log(entrega.event_id, entrega.response_status, entrega.last_error);
await emitay.webhookEndpoints.retryDelivery(entrega.id); // la reenvía ya
}

Para producción, registra la URL pública de tu receptor. En el panel, en Webhooks, con Agregar endpoint; o por la API:

Ventana de terminal
curl https://api.emitay.com/v1/webhook_endpoints \
-H "Authorization: Bearer $EMITAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.miempresa.pe/webhooks/emitay",
"events": ["document.accepted", "document.observed", "document.rejected"]
}'
  • La respuesta trae el secreto (whsec_…). Se muestra esta única vez: guárdalo en EMITAY_WEBHOOK_SECRET. Si lo pierdes, rotate_secret genera otro e invalida el anterior.
  • events son los tipos que quieres recibir; sin él, o con ["*"], todos.
  • La URL debe ser https y pública. Emitay no entrega a redes privadas.
  • Cada modo tiene sus endpoints: uno creado con una llave de prueba solo recibe eventos de prueba.
  • Deshabilitar un endpoint (enabled: false) detiene sus entregas, también las pendientes.

La referencia está en Webhooks, y lo que llega a tu servidor, en Recursos.