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.

Phase 34 turns the invoice link into a payment link: the customer opens it without signing in and pays.