Saltar a contenido

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:

  1. Montar un endpoint HTTP en tu sistema.
  2. Registrar su URL pública en el panel de SmartDoc.
  3. 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:

from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhooks/smartdoc")
async def recibir(request: Request):
    return await webhooks.fastapi_route()(request)
from flask import Flask

app = Flask(__name__)
app.add_url_rule(
    "/webhooks/smartdoc",
    view_func=webhooks.flask_view(),
    methods=["POST"],
)
# urls.py
from django.urls import path

urlpatterns = [
    path("webhooks/smartdoc", webhooks.django_view()),
]
webhooks.serve(port=3000)

Servidor mínimo, de un solo hilo. No usar en producción.

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:

ngrok http 3000
# o
cloudflared tunnel --url http://localhost:3000

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:

evento = webhooks.verify(body=cuerpo_crudo, headers=encabezados)

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:

smartdoc.Webhooks(secret=..., dedupe=True, dedupe_size=1000)   # por omisión

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.

@webhooks.on(smartdoc.Event.INVOICE_APPROVED)
def aprobada(evento):
    venta = venta_de(evento)
    if venta.estado_fiscal == "aprobada":
        return                       # ya se procesó
    ...

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:

@webhooks.on_any()
def procesar(evento):
    entidad, numero = evento.document_key    # ("receipt", 20)
    Documento.objects.get(smartdoc_entity=entidad, smartdoc_id=numero)

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:

sd.receipts.create(
    ...,
    associated_document=smartdoc.AssociatedDocument.electronic(venta.cdc),
)

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".

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:

@webhooks.on("invoice.approved")
def aprobada(evento):
    factura = evento.fetch(sd)     # GET /invoices/{id}
    guardar(factura.raw)