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¶
| 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¶
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_fileonly 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:
- Start the server:
uv run uvicorn main:app --reload. - Tunnel with
ngrok http 8000(orcloudflared tunnel). - Set
PAC_WEBHOOK_URLto the tunnel URL. - Trigger a webhook from the Alanube dashboard, or POST a fake payload: