Webhook de notificaciones¶
GDI envia un POST a la URL de callback configurada en la API Key TAD
(BackOffice → /api-key) cuando hay una novedad para el portal. Asi el portal se
entera sin hacer polling.
| Evento | Cuando llega |
|---|---|
documents.signed |
Termino de firmarse un documento que el portal dio de alta. Trae el ID y el numero oficial. |
documents.signature_failed |
Esa firma fallo definitivamente. |
documents.notified |
Un agente municipal notifico documentos de un expediente al ciudadano. |
Disponibilidad por ambiente
El campo event viene en todos los eventos y en todos los ambientes.
Webhooks sin links: desde el 22/09/2026 los avisos no traen pdf_url ni
documents[].url en ningun ambiente (DEV, HML y produccion). La URL se pide con el ID,
como se explica abajo.
El webhook trae IDs, no links
Ningun evento trae la URL del PDF. Trae el ID del documento (y su numero oficial), y el portal pide el link cuando lo va a usar:
| Evento | Con que pedir la URL |
|---|---|
documents.signed |
GET /tad/documents/{document_id} → pdf_url |
documents.notified |
GET /tad/cases/{case_id}/documents/{document_id}/url |
Los links presignados duran 3 minutos y el webhook se reintenta con backoff de
minutos: un link adentro del aviso llegaba vencido. Hasta la version 4.2.1 del backend
documents.signed traia pdf_url y cada documento de documents.notified traia url;
si tu handler los leia, cambialo a pedir la URL con el ID.
Como rutear los eventos en tu handler
Todos llegan a la misma URL de callback y todos traen el campo event:
ruteá por ahí. Y descartá en silencio los event que no conozcas — es lo que hace
que un evento nuevo en una version futura de GDI no te tire el handler.
Evento documents.signed¶
El cierre del alta de documento: POST /tad/documents devolvio 202 y esto es lo
que avisa que la firma termino, con el numero oficial del documento.
{
"event": "documents.signed",
"document_id": "007a5613-f796-4280-8f3a-ddf60e6c6743",
"official_number": "PROV-2026-00003039-MDEV-TAD",
"status": "signed",
"sent_at": "2026-07-24T18:12:31.412Z"
}
sent_at es el momento del envio y se refresca en cada reintento.
Para obtener el PDF, pedi el link con el document_id a
GET /tad/documents/{id}: la respuesta
trae un pdf_url recien firmado, valido por 3 minutos (180 s). Si el portal quiere guardar
el PDF, conviene descargarlo apenas pide el link. Si el link vence, se pide otro: no hay limite.
Evento documents.signature_failed¶
La firma de ese documento no salio y no se va a reintentar sola. El documento queda en el sistema sin numerar.
{
"event": "documents.signature_failed",
"document_id": "007a5613-f796-4280-8f3a-ddf60e6c6743",
"status": "failed",
"failure_reason": "notary_business_error",
"sent_at": "2026-07-24T18:12:31.412Z"
}
failure_reason es un codigo para soporte, no un texto para mostrarle al ciudadano. Que
significa cada valor y cuando conviene escalar en vez de reintentar esta en
Documentos.
Ante este evento, el portal puede volver a dar de alta el documento.
El re-alta va con una Idempotency-Key NUEVA
La clave del alta original ya quedo consumida: ese pedido si fue aceptado, lo que
fallo fue la firma posterior. Si reintentas con la misma clave recibis el 202 viejo
con Idempotent-Replay: true y el document_id de siempre — no se crea nada y el
tramite queda esperando para siempre un aviso que ya llego y fracaso.
Evento documents.notified¶
{
"event": "documents.notified",
"sent_at": "2026-07-24T18:11:09.097Z",
"municipality": {"name": "Municipalidad del Futuro", "acronym": "MDF"},
"citizen": {
"id": "2c6d5586-9cc7-46ad-809d-e9aa437002cc",
"country_id": "27333444556",
"full_name": "Maria Portal"
},
"case": {
"id": "584ee1f9-2237-4d69-93dd-0dae54f43ba2",
"number": "EE-2026-000227-MDF-INNO",
"reference": "Habilitacion comercial local Calle Falsa 123"
},
"documents": [
{
"id": "8bd9b4a2-692d-44dc-826f-22c6533545ac",
"official_number": "CAEX-2026-00003045-MDF-TAD",
"name": "Creacion EE-2026-000227-MDF-INNO"
}
]
}
Los documentos llegan con su id, sin link. Para descargar cada uno, pedi la URL con
GET /tad/cases/{case_id}/documents/{document_id}/url:
devuelve un link presignado de 3 minutos (180 s), y se puede pedir de nuevo cuando venza.
Verificacion de firma HMAC¶
Cada request lleva el header:
La firma es HMAC-SHA256 con el webhook secret de la API Key (se muestra una unica vez al configurarlo en BackOffice), sobre el payload:
donde path_del_callback es el path de la URL configurada (sin query string) y sha256_hex(body) es el hash SHA-256 en hexadecimal del cuerpo crudo del request.
Verificacion en Python:
import base64, hashlib, hmac, time
def verify_gdi_signature(header: str, secret: str, path: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, received = parts["t"], parts["v1"]
if abs(time.time() - int(ts)) > 300: # ventana anti-replay: 5 minutos
return False
body_hash = hashlib.sha256(body).hexdigest()
payload = f"{ts}|POST|{path}|{body_hash}".encode()
expected = base64.b64encode(
hmac.new(secret.encode(), payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, received)
Verificar siempre la firma
Sin verificacion, cualquiera que conozca la URL del callback puede inyectar notificaciones falsas. Rechazar requests con firma invalida o timestamp fuera de la ventana de 5 minutos.
Entrega y reintentos¶
- El portal debe responder
2xxrapido (idealmente encolar y procesar despues). - Si el envio falla, GDI reintenta con backoff exponencial durante varias horas.
- La entrega es al menos una vez: ante reintentos el portal puede recibir el mismo evento repetido. Para deduplicar: en los eventos de firma, por
document_id(si ese documento ya tiene numero guardado, descartar); endocuments.notified, por el par (case.id,documents[].id).sent_atno sirve para deduplicar: se refresca en cada reintento.
Probar el webhook (sandbox)¶
Dispara un webhook de prueba al webhook_url configurado en tu API Key TAD, firmado con el mismo HMAC que un webhook real. Sirve para verificar que tu receptor recibe el POST y valida bien la firma, sin depender de un tramite real ni tocar produccion. No lleva X-Citizen-ID ni body.
El evento es webhook.test (no documents.notified) y trae datos ficticios: tu receptor debe reconocerlo y no procesarlo como una notificacion real.
curl -X POST "https://gateway.your-domain.com/api/v1/tad/webhook/test" \
-H "X-API-Key: tu-api-key-tad"
La respuesta te dice que respondio tu propio servidor (la llamada es sincrona):
{
"delivered": true,
"webhook_url": "https://portal.tu-muni.gob.ar/avisos-gdi",
"status_code": 200,
"signature": "t=1784924465,v1=4//7q...",
"event": "webhook.test",
"error": null
}
delivered: true→ tu servidor respondio2xx: recibe y (si tu codigo valida la firma) el circuito funciona.delivered: falseconstatus_code→ tu servidor respondio pero con error (revisar tu handler); constatus_code: nullyerror→ GDI no pudo ni conectar (URL mal, DNS, TLS, timeout, firewall).422→ tu municipio todavia no tiene una API Key TAD conwebhook_urly secret configurados en BackOffice.
El cuerpo del webhook.test que recibe tu servidor tiene la misma forma que documents.notified, mas un campo note que aclara que es una prueba:
{
"event": "webhook.test",
"sent_at": "2026-07-24T20:00:00.000Z",
"municipality": {"name": "...", "acronym": "..."},
"citizen": {"id": "00000000-...", "country_id": "20000000001", "full_name": "Ciudadano de Prueba"},
"case": {"id": "00000000-...", "number": "EE-2026-000000-TEST-XXXX", "reference": "Expediente de prueba (webhook.test)"},
"documents": [{"id": "00000000-...", "official_number": "TEST-2026-00000000-XXXX-TAD", "name": "Documento de prueba"}],
"note": "Webhook de PRUEBA disparado desde POST /api/v1/tad/webhook/test. No corresponde a un tramite real."
}