Webhooks¶
Cuando un documento cambia de estado, SmartDoc manda un POST a una URL de tu
sistema. Es la alternativa a consultar con wait_until_final, y en producción
es lo que conviene: el polling consume cuota del límite de solicitudes por
segundo.
El endpoint que recibe ese POST lo escribís vos, en tu aplicación. El SDK pone
el otro lado: verifica la firma y llama al handler que corresponda.
Son tres pasos:
- Montar un endpoint HTTP en tu sistema.
- Registrar su URL pública en el panel de SmartDoc.
- Guardar el secreto que te da el panel y pasárselo al SDK.
1. Montar el endpoint¶
import os
import smartdoc
webhooks = smartdoc.Webhooks(secret=os.environ["SMARTDOC_WEBHOOK_SECRET"])
@webhooks.on("invoice.approved")
def factura_aprobada(evento):
print(evento.entity_id, evento.cdc)
@webhooks.on_any_error()
def algo_falló(evento):
avisar(f"{evento.event_type}: {evento.error_code} {evento.error_message}")
Y se expone en la ruta que prefieras:
No hace falta tener instalado ninguno de los tres frameworks para usar el resto del SDK.
2. Registrar la URL en el panel¶
En el panel de SmartDoc, en el menú lateral: Conectividad → Webhooks → Agregar. El formulario pide:
| Campo | Qué va |
|---|---|
| Nombre | Cómo lo vas a reconocer en la lista, por ejemplo Integración ERP |
| URL | La dirección pública de tu endpoint. Tiene que empezar con https:// |
| Activo | Si empieza recibiendo eventos |
| Tipos de evento | A cuáles te suscribís, agrupados por documento |
La URL es la de tu servidor, con la ruta que hayas montado en el paso anterior:
https://mi-servidor.com/webhooks/smartdoc.
3. Guardar el secreto¶
Al guardar el formulario, el panel muestra el secreto una sola vez, con un
botón para copiarlo. Guardalo donde guardes el resto de tus credenciales; es lo
que va en smartdoc.Webhooks(secret=...).
Si lo perdés, hay que generar otro desde el panel.
Probar en local mientras desarrollás¶
Durante el desarrollo tu servidor corre en localhost, y esa dirección no sirve
como URL registrada: el panel pide una pública y con https://. Para llegar a tu
máquina, levantá un túnel:
Registrá en el panel la URL pública que devuelva y probá contra ella. Cada
endpoint tiene un botón Enviar evento de prueba que manda un webhook.test
firmado y muestra con qué respondió tu servidor, así verificás conectividad y
firma sin emitir nada.
Al pasar a producción, lo habitual es cambiar esa URL por la del dominio donde quede publicada tu aplicación.
Verificar a mano¶
Si preferís poner vos el ruteo:
Devuelve un WebhookEvent o levanta SignatureError.
La firma es HMAC-SHA256 del cuerpo con el secreto del endpoint, en el
encabezado X-Webhook-Signature: sha256=<hex>.
El cuerpo tiene que ser el crudo
Si el framework parsea el JSON y lo volvés a serializar, el texto cambia —separadores, orden de claves, indentación— y la firma deja de coincidir.
# ✗ mal: reserializado
webhooks.verify(json.dumps(request.json).encode(), headers)
# ✓ bien: bytes crudos
webhooks.verify(request.get_data(), headers) # Flask
webhooks.verify(await request.body(), headers) # FastAPI
webhooks.verify(request.body, headers) # Django
Los adaptadores del SDK ya toman los bytes antes de que nadie los toque.
La comparación se hace con hmac.compare_digest, en tiempo constante.
Protección contra reenvíos¶
El timestamp viaja dentro del cuerpo firmado, así que no se puede alterar sin
invalidar la firma. Por omisión se rechazan entregas de más de cinco minutos:
smartdoc.Webhooks(secret=..., max_age=300) # por omisión
smartdoc.Webhooks(secret=..., max_age=None) # sin límite
Un reintento legítimo trae siempre un timestamp nuevo, así que la ventana no lo
afecta.
Handlers idempotentes¶
Una misma entrega puede llegar más de una vez, sobre todo si tu servidor respondió tarde. El SDK deduplica por el id de entrega:
Esa deduplicación es en memoria: si corrés varias instancias o el proceso se
reinicia seguido, conviene deduplicar también en tu base contra evento.id.
Escribí handlers idempotentes de todas formas
Tu handler puede correr más de una vez para el mismo hecho, y ningún filtro por id lo evita del todo. Escribir el CDC dos veces es inofensivo; mandar el mail o cobrar dos veces, no.
Identificar el documento¶
entity_id se numera por tipo de documento
El recibo 20 y la nota de débito 20 existen a la vez. Si guardás solo el número y buscás por él, vas a cruzar documentos distintos.
La clave correcta es el par (entity_type, entity_id), que el SDK expone
listo para usar:
Guardar el CDC¶
Guardá el CDC apenas llega
El CDC viene en el evento de aprobación, y es lo que hace falta para emitir un recibo o una nota sobre ese documento. Guardalo vinculado a la operación en tu base:
@webhooks.on(smartdoc.Event.INVOICE_APPROVED)
def factura_aprobada(evento):
entidad, numero = evento.document_key
venta = Venta.objects.get(smartdoc_entity=entidad, smartdoc_id=numero)
venta.cdc = evento.cdc
venta.estado_fiscal = "aprobada"
venta.save()
Después, para cobrar esa venta:
Los recibos no tienen CDC propio: evento.cdc viene en None.
Responder rápido¶
Diez segundos
Si tu servidor tarda más, la entrega se marca fallida y se reintenta aunque la hayas procesado bien. Encolá el trabajo pesado y respondé de inmediato.
@webhooks.on("invoice.approved")
def aprobada(evento):
cola.enqueue(procesar_factura, evento.entity_id) # vuelve enseguida
Los reintentos son hasta cinco, con backoff de 5, 10, 20 y 40 segundos.
Si un handler falla¶
El SDK loguea la excepción y responde 200 igual. Reintentar no arregla un error de programación: solo lo repite. Lo que corresponde es mirar el log.
Cada evento es el estado actual¶
No llegan todas las transiciones
Cuando aparece un evento nuevo de un documento, los anteriores que seguían pendientes de entrega se descartan. Podés no ver los estados intermedios.
No construyas una máquina de estados que espere cada paso. Tratá cada evento como "este documento está ahora en este estado".
El catálogo¶
23 eventos, con formato {entidad}.{subtipo}: seis entidades por cuatro
subtipos, menos receipt.error, que no existe.
| Subtipo | Significado |
|---|---|
approved |
El documento quedó válido fiscalmente |
cancelled |
Se solicitó o se completó la anulación |
pending |
Estado intermedio; no es final |
error |
Quedó en error; el motivo está en error_code y error_message |
receipt.approved no significa aprobado por la DNIT
El recibo no se envía a la DNIT. Ese evento quiere decir que el recibo se
generó y su KuDE está disponible, con el documento en estado generated.
Qué trae el evento¶
@webhooks.on_any()
def loguear(evento):
evento.id # id de la entrega
evento.event_type # Event.INVOICE_APPROVED
evento.entity_type # EntityType.INVOICE
evento.entity_id # id del documento en la API
evento.timestamp # datetime con zona
evento.status # DocumentStatus.APPROVED_BY_SET
evento.cdc # None en recibos
evento.is_approved
evento.is_error
evento.is_final
evento.error_code # None si el documento no está en error
evento.error_message
Está garantizado un subconjunto de evento.data: id, status, los datos del
emisor, el timbrado, el establecimiento y el punto de expedición. El resto
—invoiceNumber, creditNoteNumber…— puede cambiar sin aviso.
Si necesitás el detalle completo, usá el evento como disparador y traé el documento: