Webhooks and hosted invoices
Stable. Describes behaviour that is built and covered by tests in this repository.
What a tenant's own systems can learn from Payrudder without polling, and what a tenant can hand to its customers without building a page. Built in phase 23.
Webhook endpoints
A tenant registers up to 20 HTTPS endpoints (POST /v1/webhook-endpoints, or the console's Webhooks tab). Each has a URL, an optional description and the event types it wants (none ticked: every type). The create response carries the signing secret
(whsec_…) once; it is stored sealed with PAYRUDDER_SECRETS_KEY and cannot be read back, only rotated (POST …/rotate-secret: the new secret is returned once and the old one keeps signing for 24 hours so a receiver can switch without losing a delivery).
The URL must be https://, must not carry credentials and must resolve to a public address. The check is made when the connection is opened, after DNS, so a name that points inward (or changes its answer) is refused; redirects are not followed and no proxy is used (pkg/safehttp).
A development stack may lift the address rule with PAYRUDDER_WEBHOOK_ALLOW_PRIVATE=true (the compose file does; the Helm chart never does).
Event types
| Type | When | data |
|---|---|---|
invoice.finalized |
an invoice gets its number | invoice_id, number, customer_id, customer_external_id, period_id, currency, total_minor, status |
invoice.paid · invoice.voided · invoice.uncollectible |
the invoice reaches that state | the same, plus environment (sandbox or live; invoice.finalized does not carry it); invoice.paid also tax_minor and net_minor (the total less its TAX lines: what a partner commission is a share of). status is paid, void (not voided) or uncollectible |
credit_note.created |
a credit note is issued | credit_note_id, number, invoice_id, invoice_number, customer_id, customer_external_id, currency, total_minor, tax_minor (the part of total_minor that is the invoice's tax given back; 0 when none), reason, disposition (credit/refund), and for a refund refund_state |
credit_note.refunded · credit_note.refund_failed |
the provider settled (or refused) a refund to the card, or an operator confirmed after checking at the provider that a refund of unknown outcome was made | the same, with the outcome (refund_error on failure) |
subscription.created |
a subscription is created | subscription, as the API returns it but without the fields at their zero value (an empty string, 0, false and an unset time are left out); scheduled_change and coupon_code only on subscription.created, subscription.changed and subscription.change_scheduled |
subscription.changed |
a plan or interval change took effect (now, or at the end of the period) | subscription (the new one), previous_subscription_id, quote; a change that was scheduled and applied at the end of the period has scheduled: true and no quote |
subscription.change_scheduled |
a change was set for the end of the period | subscription, quote |
subscription.cancelled |
it ended now (credited_minor, a string, when the unused fee was credited). A cancellation with a future end_time is subscription.cancellation_scheduled; nothing is sent when that day arrives |
subscription |
subscription.cancellation_scheduled · subscription.cancellation_withdrawn |
cancel at period end was set / taken back | subscription |
subscription.paused · subscription.resumed |
subscription |
|
subscription.moved_to_provider |
a provider migration took over | subscription, previous_subscription_id, to_provider |
subscription.trial_will_end |
a trial ends in 3 days or fewer (PAYRUDDER_TRIAL_REMINDER_DAYS); once per trial end: an extended or restarted trial is announced again, a plan change that keeps the trial end is not. Not sent for a subscription a provider runs, or one that is paused or set to cancel |
subscription (its trial_end_time says when) |
payment_link.created |
a payment link was made (not the invoice links an ad-hoc link hands out) | link_id, kind (invoice, adhoc or credit_pack), single_use, expires_at; when set: invoice_id, amount_minor, currency, description, customer_external_id, and for a credit pack pack_id, quantity, credits |
payment_link.paid |
an invoice was paid through the link (once per link and invoice) | link_id, kind, invoice_id, invoice_number, customer_external_id, currency, total_minor, paid_count |
payment_link.expired |
the link expired unpaid (a single-use link that was paid is used, not expired) | link_id, kind, expires_at; when set: invoice_id, amount_minor, currency, customer_external_id |
payment.disputed |
a customer questioned a payment with their bank, or the case moved on or ended: one event per step (a case that opens, and later is won or lost, is two or three), each once however many notifications the provider sends for it. Stripe: charge.dispute.created, .updated, .closed, .funds_withdrawn, .funds_reinstated (the endpoint the registration helper makes is enabled for them; an endpoint made earlier needs them added at Stripe); Paddle: the chargeback and chargeback_warning adjustments and their reversals. A dispute of a payment Payrudder did not make sends nothing |
invoice_id, invoice_number, customer_external_id, provider, dispute_id (the provider's reference of the case, the same in every event about it), payment_ref, amount_minor (a string), currency, outcome (open, won or lost), formal (true for a chargeback, which takes the money back; false for an inquiry or a warning), reason (the cardholder's, in the provider's words; may be empty), provider_status; when the provider gives a date: evidence_due_time |
credits.purchased |
a credit pack was paid and its credits granted (once per paid invoice) | customer_id, customer_external_id, credits (the pack's and the bonus of a coupon, in one lot), bonus_credits (the part a coupon added, 0 without one), quantity, balance (spendable after the grant), pack_id, pack_name, link_id, invoice_id, invoice_number, currency, amount_minor, expires_at (absent when they never expire) |
credits.low |
a consume took a customer's balance from at least the tenant's threshold (PUT /v1/credits/settings, default off) to below it; once per crossing |
customer_id, customer_external_id, balance, threshold |
contract.accepted |
a customer accepted one or more agreements together (a hosted payment page, or the seller's server through POST /v1/customers/{id}/contract-acceptances): one event for the group, not for each version |
group_id, source (hosted, api), accepted_at, snapshot_sha256, items (acceptance_id, kind, audience, version_id, version, sha256, locale), immediate_start_consent, withdrawal_waiver_acknowledged, context (kind, ref); customer_id and customer_external_id unless it was a guest payment |
contract.version_published |
a new version of an agreement was published (POST /v1/contracts/versions) |
version_id, kind, audience, number, material, sha256, locales, published_at |
contract.withdrawal_requested |
a customer's withdrawal statement was recorded (POST /v1/customers/{id}/contract-withdrawals) |
withdrawal_id, customer_id, customer_external_id, subscription_id, invoice_id (the last two may be empty), source, received_at |
contract.withdrawal_decided |
the seller accepted or rejected a withdrawal, or marked an accepted one refunded (PUT /v1/contract-withdrawals/{id}/status). The refund a decision to accept asks of billing is announced by credit_note.created |
withdrawal_id, customer_id, customer_external_id, subscription_id, invoice_id (the last two may be empty), status, decided_by, decided_at, note |
customer.blocked |
a customer was blocked (POST /v1/customers/{id}/block); blocking one who is blocked already sends nothing |
customer_id, customer_external_id, category (fraud, chargeback, abuse, request, other or empty), reason (the seller's own note), blocked_until (RFC 3339) when the block ends by itself, and suspend_service (true when the block also suspended the customer's service; absent on an event queued before the field existed) |
customer.unblocked |
a block was lifted (POST /v1/customers/{id}/unblock), or ran out by itself (blocked_until passed): a sweep that runs every few minutes clears the row and sends it, so the event can come minutes after the end |
customer_id, customer_external_id |
price_change.scheduled |
a run of a price change (POST /v1/plans/{code}/price-changes, or POST /v1/price-changes/{id}/retry) scheduled at least one subscription; once per run, not for a dry run. Each subscription also sends subscription.change_scheduled |
price_change (as GET /v1/price-changes/{id} returns it, without the fields at their zero value: id, plan_code, from_version, to_version, notice_days, effective_time, state, created_by, create_time and the counts scheduled, applied, withdrawn, skipped), scheduled and skipped (what that run did) |
A tenant the operator has suspended, frozen or banned sends no webhooks: its deliveries wait (without using up an attempt, for up to the 30 days events are kept) and go out when the tenant is reinstated.
A delivery is a JSON POST:
{ "id": "0192…", "type": "invoice.paid", "created": "2026-10-20T10:00:00Z", "tenant_id": "0192…", "data": { "invoice_id": "…", "number": "INV-2026-000007", "total_minor": "1116", "tax_minor": "186", "net_minor": "930", "environment": "live", "status": "paid" } }
The body is exactly id, type, created (second precision, UTC), tenant_id and data; there is no API version field. Money is a string of minor units, as in the REST API; the credit counts of credits.* events (credits, bonus_credits, balance, threshold, and credits of payment_link.created) are JSON numbers. The machine-readable shape of every data is in api/openapi/v1.yaml (WebhookEvent, one schema per type). Events are delivered at least once and not in order: use id to ignore a repeat, and fetch the object from the API when you need its present state. A ping event is sent by POST …/test; its data is {"message": "This is a test event from Payrudder."} and it cannot be subscribed to.
Verifying a delivery
Every delivery carries
Payrudder-Signature: t=1700000000,v1=<hex>[,v1=<hex>]
Payrudder-Event-Id: <id> Payrudder-Event-Type: <type> Payrudder-Delivery-Attempt: <n>
where each v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex (two while a rotated secret is still accepted). Compute it over the raw bytes, compare in constant time, and reject a t more than five minutes from your clock.
import "github.com/0furkancolak/payrudder/pkg/webhooksig"
err := webhooksig.Verify(r.Header.Get(webhooksig.Header), rawBody, secret, time.Now(), 0)
const crypto = require("node:crypto");
function verify(header, rawBody, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(now - t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return header.split(",").filter((p) => p.startsWith("v1=")).some((p) => {
const got = Buffer.from(p.slice(3), "hex"), w = Buffer.from(want, "hex");
return got.length === w.length && crypto.timingSafeEqual(got, w);
});
}
import hmac, hashlib, time
def verify(header: str, raw_body: bytes, secret: str, tolerance=300) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = int(next(v for k, v in parts if k == "t"))
if abs(time.time() - t) > tolerance:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, want) for k, v in parts if k == "v1")
Delivery
Answer 2xx within 10 seconds. Anything else (a status, a timeout, a refused connection) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours; after the seventh attempt the delivery is failed. GET /v1/webhook-deliveries?endpoint_id= shows each delivery with its attempts and the last answer,
POST /v1/webhook-deliveries/{id}/redeliver sends one again from the start, and a paused endpoint (active: false) receives nothing new. Deliveries and events are kept for 30 days.
Where events come from: subscription events are written in the same transaction as the change, so an event exists exactly when the change does. Invoice and credit note events are written to billing-svc's tenant_event_outbox in the transaction that changes the invoice and handed to plans-svc (which owns the endpoints) by a loop that retries until plans-svc has recorded them;
the event id is derived from the occurrence, so a repeat is harmless. If no endpoint wants a type, nothing is stored.
Hosted invoice page
POST /v1/invoices/{id}/links ({"expires_in_days": 90}, 1–365) returns {token, url, expires_at} for a finalized invoice. The tenant sends the url to its customer; GET /i/{token} shows the invoice (lines, service periods, subtotal, total, status) and GET /i/{token}/pdf redirects to a five-minute download link
once the PDF is rendered. No sign-in: the token is 256 random bits and only its SHA-256 is stored. DELETE /v1/invoices/{id}/links revokes every link of the invoice. An unknown, expired or revoked token answers the same 404 page.
The page is rendered by the gateway with escaped HTML, in English, Turkish, German, French, Spanish or Arabic (?lang= or the browser's language), and sends Cache-Control: no-store, X-Robots-Tag: noindex, Referrer-Policy: no-referrer and a CSP that allows nothing to load from elsewhere. It is limited to 60 requests a minute per address
(the connection's; a proxy in front of the gateway must be the address the limiter sees). The link starts with PAYRUDDER_PUBLIC_URL when the gateway has one (compose: http://localhost:42800; Helm: global.publicURL, required outside a development environment), else it is relative.
Payment links and the customer payment page (phase 34)
Phase 34 turns the invoice link into a payment link: the customer opens it without signing in and pays.
POST /v1/payment-linkswithkind: invoice(invoice_id, an unpaid finalized invoice) orkind: adhoc(amount_minor, optionalcurrency,description,customer_external_id). The token is returned once (token,url).Idempotency-Key(oridempotency_key) makes a retry return the first link without its token (replayed: true); the same key with other parameters isbilling.payment_link.idempotency_conflict.single_usedefaults to true for ad hoc, false for an invoice link; links last 30 days (ad hoc) or 90 days, at most 365.- An ad-hoc link is a factory: its page shows the request and a Continue button. Continue (not the page view) makes the invoice (kind
LINK, gap-free number, the same saga; no tax is added) and hands the payer an invoice link. A single-use link is consumed by its first paid invoice; a reusable one makes an invoice per use. Revoking it (DELETE /v1/payment-links/{id}) or its expiry voids the invoices nobody paid. - The invoice page shows the amount, lines, seller (name, logo, address) and the methods the invoice has:
mockfor sandbox tenants andPAYRUDDER_DEMO; for live tenants whatever payments-worker's provider catalogue offers (a hosted checkout of the routed provider, payment instructions of a manual provider such as a bank transfer). The state shown isopen,processing,failed,paidorvoid; whileprocessingthe page shows a "Check again" link (it no longer reloads itself; seedocs/hosted-pages.md). Only the saga and the ledger change the state: a provider's confirmed notification reaches the saga asSettled; the browser's return from a checkout shows the state and settles nothing. POST /v1/customers/{external_id}/portal-linksmakes the customer's page (/c/{token}, 30 days by default): their invoices, payments (ledger rows: charge or refund, status, date, amount, provider), PDFs, printable receipts and a pay button on open invoices. It is read-only and scoped to that customer of that tenant;DELETErevokes.GET /v1/customers/{external_id}/paymentslists the same rows for the tenant.- Public routes:
/i/{token}(/start,/pay,/pdf,/receipt,/logo) and/c/{token}(/logo,/invoices/{id},/invoices/{id}/pdf|receipt|pay). Same headers and 60 requests a minute as the invoice page, 10 a minute for the POSTs that start a payment. - The saga waits in
AWAITING_PAYMENTfor a link-collected invoice (no stored method is charged, a decline is the customer's to retry, no dunning). An invoice in dunning can be paid through its link too; invoices the dunning gave up on cannot.