Ir al contenido
SitioCrear cuenta

Guías

Webhooks

Recibí los veredictos de Hacienda en tu servidor, verificá la firma de cada entrega y procesá los eventos una sola vez.

Hacienda responde de forma asíncrona: el veredicto de un comprobante llega segundos o minutos después del 201. En vez de consultar en ciclo, registrá un webhook y Aster le envía a tu servidor un POST firmado cada vez que algo cambia.

Terminal
curl https://aster.astranexo.com/api/v1/webhooks \
-H "Authorization: Bearer aster_test_8KfQ2mVx3TzL9pRw" \
-H "Content-Type: application/json" \
--data '{
"name": "Veredictos de Hacienda",
"url": "https://erp.ferreterialasabana.cr/webhooks/aster",
"events": ["document.accepted", "document.rejected", "document.voided"],
"is_active": true
}'
201 Created
{
"success": true,
"data": {
"id": 7,
"name": "Veredictos de Hacienda",
"url": "https://erp.ferreterialasabana.cr/webhooks/aster",
"events": ["document.accepted", "document.rejected", "document.voided"],
"is_active": true,
"secret": "9f2c4e71b8a0d35c6e1f0a92b7d48c3e5a6b1f0e2d9c8a7b"
}
}

Reglas de la URL:

  • Solo https://.
  • Un host público: no se aceptan localhost, direcciones privadas, de loopback ni link-local.
  • Sin credenciales en la URL (https://usuario:clave@...).
  • Aster sigue como máximo 3 redirecciones, y no se conecta a direcciones no públicas aunque el DNS cambie después de crear el webhook.

Los webhooks pertenecen a la organización de la llave con la que los creás. Crear necesita el permiso webhooks:write; modificar, probar y borrar, webhooks:manage.

Evento Cuándo se envía
document.created Se creó el registro del comprobante
document.signed El comprobante quedó firmado y el XML generado
document.queued El comprobante se envió a Hacienda
document.accepted Hacienda aceptó el comprobante
document.rejected Hacienda rechazó el comprobante
document.error Ocurrió un error de procesamiento antes de llegar a Hacienda
document.voided El comprobante quedó anulado por una nota de crédito aceptada. Ver anular
callback.received Llegó una respuesta de Hacienda

Para la mayoría de las integraciones alcanza con document.accepted, document.rejected y document.voided.

Cada entrega es un POST con un cuerpo JSON y estos encabezados:

Encabezados de una entrega
POST /webhooks/aster HTTP/1.1
Content-Type: application/json
X-Webhook-Event: document.accepted
X-Webhook-ID: evt_01J9ZK4M7Q2R8T5V3W6X9Y0A1B
X-Webhook-Timestamp: 1791476431
X-Webhook-Signature: sha256=5d41c0f6e1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c
X-Webhook-Signature-V2: t=1791476431,v1=8a1f9b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8
Cuerpo de document.accepted
{
"document_key": "50608102600310165432100100001010000001042147201936",
"document_type": "01",
"status": "accepted",
"hacienda_status": "accepted",
"hacienda_message": "Documento aceptado"
}

En un rechazo, status es rejected y hacienda_message trae el texto de Hacienda tal como lo envió. Para el detalle completo del comprobante, consultá GET /electronic-documents/{clave} con la clave del cuerpo.

Encabezado Contenido
X-Webhook-Event El nombre del evento
X-Webhook-ID Identificador único del evento. Es el mismo en todos los reintentos de ese evento
X-Webhook-Timestamp Momento de la firma, en segundos Unix
X-Webhook-Signature-V2 t= con el mismo timestamp y v1= con la firma en hexadecimal
X-Webhook-Signature Firma anterior, sin timestamp: sha256= seguido del hexadecimal

Verificá cada entrega antes de procesarla. Usá X-Webhook-Signature-V2: incluye el timestamp en lo firmado, así que también te protege de que alguien reenvíe una entrega vieja.

  1. Leé el cuerpo crudo, byte por byte, antes de parsear el JSON. Si tu framework parsea y vuelve a serializar, la firma no coincide.
  2. Del encabezado X-Webhook-Signature-V2, separá t y v1.
  3. Rechazá la entrega si t difiere en más de 5 minutos de tu reloj.
  4. Calculá HMAC-SHA256 con tu secreto sobre el texto t, un punto y el cuerpo crudo: "<t>.<cuerpo>".
  5. Compará tu resultado en hexadecimal con v1 usando una comparación de tiempo constante.
server.mjs
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.ASTER_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((part) => {
const i = part.indexOf('=');
return [part.slice(0, i), part.slice(i + 1)];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}
const app = express();
// express.raw deja el cuerpo como Buffer, sin parsear.
app.post('/webhooks/aster', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.get('X-Webhook-Signature-V2'))) {
return res.status(401).end();
}
const eventId = req.get('X-Webhook-ID');
const event = req.get('X-Webhook-Event');
const payload = JSON.parse(req.body.toString('utf8'));
await procesarEvento(eventId, event, payload);
res.status(200).end();
});
async function procesarEvento(eventId, event, payload) {
// Guardá eventId con un índice único y procesá solo si es nuevo.
console.log(eventId, event, payload.document_key, payload.status);
}
app.listen(3000);

Aster entrega cada evento al menos una vez: si tu servidor tarda, se cae o responde con error, el mismo evento vuelve a llegar. Diseñá el receptor para que procesar dos veces no tenga efecto:

  • Guardá X-Webhook-ID en una tabla con un índice único antes de procesar. Si ya existe, respondé 200 y no hagas nada más.
  • Respondé 2xx rápido, en pocos segundos. Si el trabajo es largo (generar asientos, enviar correos), encolalo y respondé después de encolar.
  • No dependas del orden de llegada. Si necesitás el estado actual, consultá GET /electronic-documents/{clave}.

Una entrega cuenta como exitosa cuando tu servidor responde con un código 2xx. Cualquier otra respuesta, un timeout o un error de conexión programa un reintento.

Las entregas se guardan en una cola persistente, así que un reinicio de Aster no las pierde. Los reintentos usan espera exponencial y se extienden durante unas 24 horas. Si tu servidor sigue fallando después del último intento, la entrega queda marcada como fallida y podés reenviarla a mano.

Terminal
curl https://aster.astranexo.com/api/v1/webhooks/7/deliveries \
-H "Authorization: Bearer aster_test_8KfQ2mVx3TzL9pRw"
200 OK (extracto)
{
"success": true,
"data": [
{
"id": 98123,
"event": "document.accepted",
"event_id": "evt_01J9ZK4M7Q2R8T5V3W6X9Y0A1B",
"status": "failed",
"attempts": 12,
"response_status": 503,
"created_at": "2026-10-07T16:20:31Z",
"last_attempt_at": "2026-10-08T15:58:02Z"
}
]
}

Cuando tu servidor vuelva a estar disponible, reenviá una entrega:

Terminal
curl -X POST https://aster.astranexo.com/api/v1/webhooks/7/deliveries/98123/redeliver \
-H "Authorization: Bearer aster_test_8KfQ2mVx3TzL9pRw"

El reenvío lleva el mismo X-Webhook-ID y el mismo cuerpo, con un timestamp y una firma nuevos. Tu deduplicación por X-Webhook-ID lo maneja sin cambios.

  • POST /webhooks/{id}/test envía una entrega de prueba en el momento y te devuelve el resultado: el código HTTP de tu servidor y el tiempo de respuesta. Usalo para validar la URL y la verificación de firma.
  • POST /webhooks/{id}/regenerate-secret genera un secreto nuevo y lo devuelve una sola vez. Las entregas siguientes se firman con él; actualizalo en tu servidor de inmediato.
  • PUT /webhooks/{id} cambia la URL, los eventos o is_active. DELETE /webhooks/{id} lo borra.