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.
Crear un webhook
Sección titulada «Crear un webhook»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 }'{ "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.
Eventos
Sección titulada «Eventos»| 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.
La entrega
Sección titulada «La entrega»Cada entrega es un POST con un cuerpo JSON y estos encabezados:
POST /webhooks/aster HTTP/1.1Content-Type: application/jsonX-Webhook-Event: document.acceptedX-Webhook-ID: evt_01J9ZK4M7Q2R8T5V3W6X9Y0A1BX-Webhook-Timestamp: 1791476431X-Webhook-Signature: sha256=5d41c0f6e1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4cX-Webhook-Signature-V2: t=1791476431,v1=8a1f9b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8{ "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 |
Verificar la firma
Sección titulada «Verificar la firma»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.
- Leé el cuerpo crudo, byte por byte, antes de parsear el JSON. Si tu framework parsea y vuelve a serializar, la firma no coincide.
- Del encabezado
X-Webhook-Signature-V2, separátyv1. - Rechazá la entrega si
tdifiere en más de 5 minutos de tu reloj. - Calculá
HMAC-SHA256con tu secreto sobre el textot, un punto y el cuerpo crudo:"<t>.<cuerpo>". - Compará tu resultado en hexadecimal con
v1usando una comparación de tiempo constante.
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);<?php$secret = getenv('ASTER_WEBHOOK_SECRET');$tolerance = 300;
// Cuerpo crudo. En Symfony: $request->getContent().$raw = file_get_contents('php://input');$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE_V2'] ?? '';
$parts = [];foreach (explode(',', $header) as $pair) { [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); $parts[$key] = $value;}
$t = $parts['t'] ?? '';$received = $parts['v1'] ?? '';
if (!ctype_digit($t) || abs(time() - (int) $t) > $tolerance) { http_response_code(401); exit;}
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $received)) { http_response_code(401); exit;}
$eventId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';$event = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';$payload = json_decode($raw, true);
// Guardá $eventId con un índice único y procesá solo si es nuevo.
http_response_code(200);import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
SECRET = os.environ["ASTER_WEBHOOK_SECRET"].encode()TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw: bytes, header: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, received = parts.get("t", ""), parts.get("v1", "") if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received)
@app.post("/webhooks/aster")def aster_webhook(): raw = request.get_data() # cuerpo crudo, antes de parsear if not verify(raw, request.headers.get("X-Webhook-Signature-V2", "")): abort(401)
event_id = request.headers["X-Webhook-ID"] event = request.headers["X-Webhook-Event"] payload = request.get_json()
# Guardá event_id con un índice único y procesá solo si es nuevo. print(event_id, event, payload["document_key"], payload["status"]) return "", 200Con FastAPI, la misma función verify sirve; leé el cuerpo crudo con await request.body():
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
@app.post("/webhooks/aster")async def aster_webhook(request: Request) -> Response: raw = await request.body() if not verify(raw, request.headers.get("x-webhook-signature-v2", "")): raise HTTPException(status_code=401)
event_id = request.headers["x-webhook-id"] # Guardá event_id con un índice único y procesá solo si es nuevo. return Response(status_code=200)package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "log" "net/http" "os" "strconv" "strings" "time")
const tolerance = 5 * time.Minute
var secret = []byte(os.Getenv("ASTER_WEBHOOK_SECRET"))
func verify(raw []byte, header string, now time.Time) bool { var t, v1 string for _, part := range strings.Split(header, ",") { key, value, ok := strings.Cut(part, "=") if !ok { continue } switch key { case "t": t = value case "v1": v1 = value } }
ts, err := strconv.ParseInt(t, 10, 64) if err != nil { return false } age := now.Sub(time.Unix(ts, 0)) if age > tolerance || age < -tolerance { return false }
received, err := hex.DecodeString(v1) if err != nil { return false } mac := hmac.New(sha256.New, secret) mac.Write([]byte(t + ".")) mac.Write(raw) return hmac.Equal(mac.Sum(nil), received)}
func asterWebhook(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if !verify(raw, r.Header.Get("X-Webhook-Signature-V2"), time.Now()) { http.Error(w, "invalid signature", http.StatusUnauthorized) return }
eventID := r.Header.Get("X-Webhook-ID") event := r.Header.Get("X-Webhook-Event") // Guardá eventID con un índice único y procesá solo si es nuevo. log.Println(eventID, event)
w.WriteHeader(http.StatusOK)}
func main() { http.HandleFunc("POST /webhooks/aster", asterWebhook) log.Fatal(http.ListenAndServe(":8080", nil))}Procesar cada evento una sola vez
Sección titulada «Procesar cada evento una sola vez»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-IDen una tabla con un índice único antes de procesar. Si ya existe, respondé200y no hagas nada más. - Respondé
2xxrá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}.
Reintentos
Sección titulada «Reintentos»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.
Historial de entregas
Sección titulada «Historial de entregas»curl https://aster.astranexo.com/api/v1/webhooks/7/deliveries \ -H "Authorization: Bearer aster_test_8KfQ2mVx3TzL9pRw"{ "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:
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.
Probar y rotar
Sección titulada «Probar y rotar»POST /webhooks/{id}/testenví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-secretgenera 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 ois_active.DELETE /webhooks/{id}lo borra.