Approvals and webhooks
When a person must decide
If the policy says a payment needs a human (an amount over the mandate's approval threshold, a payee that is not on the allowlist, an IBAN that differs from the allowlist, or an agent risk signal), authorize returns 202 and REQUIRE_APPROVAL. No authorization exists yet, and no budget is reserved.
Polivor emails each of the tenant's approvers a link. The link works once, for 24 hours, and needs no sign-in. The approver sees the payment, the checks, and the reasons, and approves or denies with an optional comment. On approval Polivor checks again that the mandate and agent are still in force and that the budget still allows it, and then issues the authorization. If something changed in the meantime the decision becomes a denial, for example BUDGET_EXCEEDED or MANDATE_REVOKED.
Sandbox: email is not switched on yet. Approvals are still visible in the console. A payment waiting for a person shows in the live feed as Require approval. A payment you simulate in the console shows its approver link. When a person decides, the feed shows Approved or Denied, and the Evidence page holds a signed approval.resolved record with its time and hash. There is no in-app inbox or alert yet: for a payment an agent sends, the approver link is delivered by email only.
The agent finds out in one of two ways:
- Poll
GET /v1/decisions/{id}untilstatusisALLOWED(the response then holds the authorization) orDENIED. - Webhook: Polivor calls you. See below.
Webhooks
Set one URL per tenant with PUT /v1/webhook (admin key). The response shows the signing secret once; setting the URL again creates a new secret. The URL must be HTTPS and point to a public address.
{ "url": "https://payments.example.com/hooks/polivor" }
| Event | Sent when |
|---|---|
decision.resolved | An approval ended: ALLOWED (with the authorization) or DENIED (with a reason). |
authorization.reported | An execution report arrived, or an admin released an authorization. |
webhook.test | You asked for a test delivery. |
Each delivery is a POST with the JSON body { "id", "type", "created_at", "tenant_id", "data" } and two headers: Polivor-Event-Id and Polivor-Signature: t=<unix seconds>,v1=<hex>.
Check the signature
v1 is the hex HMAC-SHA256, with your secret as the key, of the text <t>.<raw body>: the timestamp, a dot, and the body exactly as received. Compare in constant time, and reject a delivery whose t is far from your clock (five minutes is a common limit) so an old request cannot be replayed.
import hashlib, hmac, time
def valid(secret: bytes, header: str, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) <= tolerance_seconds
Delivery
Any 2xx answer is success. Anything else is retried 5 times with a growing delay (1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours), always with the same event id, so make your handler idempotent on Polivor-Event-Id. Polivor follows no redirects and sends nothing to private addresses.