DEVELOPERS
Payment webhooks
The endpoint pattern, signature verification and event list behind each online payment provider.
Each online payment provider Bizinly supports posts its own webhook to the same URL pattern, scoped to the workspace and the provider:
POST https://<your-workspace-domain>/api/pay/<provider>/webhook
<provider> is one of stripe, paypal, revolut, gocardless, authorizenet or wise. These endpoints are public — no bearer token or session is expected — because each provider authenticates its own call differently, as below. A request that fails verification gets 400; the provider retries it on its own schedule. A request that verifies but describes an event Bizinly doesn't act on gets 200 — there's no need to hand-pick a narrower event subscription on the provider's side, unrecognised event types are simply ignored.
Verification, per provider
| Provider | How it's verified |
|---|---|
| Stripe | Stripe-Signature header (t=<timestamp>,v1=<hex>); Bizinly recomputes an HMAC-SHA256 of <timestamp>.<raw body> with the endpoint's signing secret and compares it, rejecting a timestamp too far from now (replay protection). |
| Revolut | Revolut-Signature (v1=<hex>) and Revolut-Request-Timestamp headers; HMAC-SHA256 of v1.<timestamp>.<raw body> with the webhook signing secret, same timestamp tolerance. |
| GoCardless | Webhook-Signature header; HMAC-SHA256 (hex) of the raw body with the webhook endpoint secret. |
| Authorize.Net | X-ANET-Signature header (sha512=<hex>); HMAC-SHA512 of the raw body with the account's Signature Key. |
| PayPal | No local signature check — the raw event, its headers and the webhook ID are sent to PayPal's own verify-webhook-signature API, and only a SUCCESS response is trusted. |
| Wise | The payload is never trusted for its contents — receiving any webhook for a payment request is only a hint to re-read that request from Wise's own API, and only what the API returns decides whether it's paid. |
All raw-body HMAC comparisons are constant-time. A missing or unconfigured secret (or webhook ID, for PayPal) always fails closed rather than skipping the check.
Idempotency — what's safe to retry
Every webhook resolves to a specific transaction: a checkout Bizinly itself created earlier for one document and one provider reference. Settling that transaction into a recorded payment only happens once — a transaction already settled is left alone, whichever call reaches it. In practice that means:
- A provider re-sending the same event (their retry policy, or you manually resending it from their dashboard) is always safe — it settles nothing a second time.
- The webhook and the return-URL the payer lands on after paying race for the same transaction; whichever arrives first settles it, the other is a no-op.
- Bizinly's own periodic reconciliation, which re-asks each provider about any transaction still awaiting confirmation, can run at any time without risk of double-settling — it uses the same settlement path as the webhook.
An event for a transaction Bizinly doesn't recognise (wrong workspace, or one it never created) is acknowledged and otherwise ignored, not treated as an error.
Events acted on
| Provider | Events |
|---|---|
| Stripe | checkout.session.completed, checkout.session.async_payment_succeeded → paid · checkout.session.async_payment_failed → failed · checkout.session.expired → expired |
| PayPal | PAYMENT.CAPTURE.COMPLETED → paid · PAYMENT.CAPTURE.REFUNDED → refunded · CHECKOUT.ORDER.VOIDED → cancelled |
| Revolut | ORDER_COMPLETED → paid · ORDER_CANCELLED → cancelled · ORDER_PAYMENT_DECLINED, ORDER_PAYMENT_FAILED → failed |
| GoCardless | payment confirmed, paid_out → paid · payment failed, cancelled → failed/cancelled · payment charged_back → refunded · a billing request turning fulfilled → re-read, to pick up the payment it created |
| Authorize.Net | net.authorize.payment.authcapture.created, net.authorize.payment.fraud.approved → re-read the transaction and settle if captured · net.authorize.payment.fraud.declined → failed · net.authorize.payment.void.created → cancelled · net.authorize.payment.refund.created → refunded |
| Wise | any event naming a payment request → re-read that request |
If a webhook isn't registered yet
Nothing is stuck waiting on it. Bizinly periodically re-asks each provider about a transaction that's still awaiting confirmation, so a payment still completes and the invoice still gets marked paid — a registered webhook just makes that happen immediately instead of on the next check.
Related: Online payments, Overview & architecture.