Polivor

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} until status is ALLOWED (the response then holds the authorization) or DENIED.
  • 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" }
EventSent when
decision.resolvedAn approval ended: ALLOWED (with the authorization) or DENIED (with a reason).
authorization.reportedAn execution report arrived, or an admin released an authorization.
webhook.testYou 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.