Skip to content

Webhooks

Alanube pushes status updates to Cifras as webhooks. This is the primary path for hearing back about a document — polling is a backup.

The endpoint

POST /webhooks/pac/{company_id}

app/webhooks/router.py

Path/Header Required? Purpose
{company_id} (path) Yes The tenant's pac_company_id. Used to resolve which tenant this event belongs to.
X-Pac-Signature (header) Yes Verified against settings.pac_webhook_secret.
X-User-Id (header) No If set, the webhook action is recorded under that user. Otherwise we use the system user.

The route is unauthenticated in the normal sense — there's no JWT/session. Signature verification is the only authentication.

Signature verification

The current check is a direct string equality:

if settings.pac_webhook_secret != x_pac_signature:
    raise HTTPException(403, detail="Invalid webhook signature")

This isn't HMAC; the secret is shared verbatim. If/when Alanube switches to HMAC, this is the place to upgrade.

The payload

app/webhooks/pac_models.py

class PacWebhookPayload(BaseModel):
    id: str                      # Alanube document_id
    legalStatus: PacLegalStatus  # PAC_AUTHORIZED | DGI_AUTHORIZED | PAC_REJECTED | DGI_REJECTED
    cufe: str | None = None
    message: str | None = None
    webhook: PacWebhookInfo | None = None

PacLegalStatus and PacDocumentType are also defined there.

The handler

PacWebhookService.process_document_status

flowchart TD
    A[Webhook received] --> B[Verify signature]
    B --> C[Resolve tenant by company_id]
    C --> D[Build webhook context
system or X-User-Id] D --> E[Lookup document by document_id] E -- found invoice --> F[_process_invoice_status] E -- found credit --> G[_process_credit_status] E -- not found --> X[Log warning, return 200] F --> F1{legalStatus} F1 -- PAC_AUTHORIZED --> FA[Fetch + attach PDF
set legal_status] F1 -- DGI_AUTHORIZED --> FB[Fetch + attach PDF
set legal_status] F1 -- PAC_REJECTED --> FC[Log + set legal_status] F1 -- DGI_REJECTED --> FD[Log + set legal_status] G --> G1{legalStatus} G1 -- PAC_AUTHORIZED --> GA[Fetch + attach PDF] G1 -- DGI_AUTHORIZED --> GB[Fetch + attach PDF
update CUFE if changed] G1 -- PAC_REJECTED --> GC[Log + set legal_status] G1 -- DGI_REJECTED --> GD[Log + set legal_status] FA --> Z[Commit transaction
return 200] FB --> Z FC --> Z FD --> Z GA --> Z GB --> Z GC --> Z GD --> Z

Idempotency

Webhooks are idempotent by design. The handler:

  • Looks up the row.
  • Sets legal_status (overwrites — fine).
  • Calls FileService.create_file only if a PDF URL is returned.

If the same webhook arrives twice, you'll get two file rows attached to the entity. This is a known wart. Filter PDFs by created_at if you only want the latest.

The lookup key

The handler tries document_id first. If the row isn't found by document_id, it falls back to matching cufe against invoice_number / credit_number. This handles the rare case where Alanube re-emits the CUFE without the original document_id.

What if we never get a webhook?

That's what Polling is for. The pac_status_poll_task runs in parallel and will pick up the same document via the GET endpoint.

Local development

To test webhooks locally:

  1. Start the server: uv run uvicorn main:app --reload.
  2. Tunnel with ngrok http 8000 (or cloudflared tunnel).
  3. Set PAC_WEBHOOK_URL to the tunnel URL.
  4. Trigger a webhook from the Alanube dashboard, or POST a fake payload:
curl -X POST http://localhost:8000/webhooks/pac/<your-pac-company-id> \
  -H "Content-Type: application/json" \
  -H "X-Pac-Signature: $PAC_WEBHOOK_SECRET" \
  -d '{
    "id": "alanube-doc-id",
    "legalStatus": "DGI_AUTHORIZED",
    "cufe": "FE0123..."
  }'