Webhooks
Receive real-time HTTP notifications when billing events happen in Paid
Receive real-time HTTP notifications when billing events happen in Paid
Paid can send HTTP POST requests to your system whenever important billing events happen. You configure webhooks in the Paid app under Settings > Webhooks, or manage them programmatically through the API.
This page covers:
Every webhook uses the same top-level envelope:
event identifies which webhook firedtimestamp is the time Paid created the delivery in RFC 3339 formatisTest is true for test deliveries sent from the UIdata contains the event-specific payload, keyed by the primary payload key listed aboveEvery delivery includes an HMAC-SHA256 signature in the x-webhook-signature header so you can verify the request came from Paid. Your organization has one signing secret, used by every webhook delivery regardless of event type. Generate or rotate it from Settings > Webhooks.
t is the delivery timestamp in milliseconds since the Unix epoch.s is the base64-encoded HMAC-SHA256 of the signed payload using your webhook’s signing secret as the key. (The trailing = is base64 padding. Do not strip it.)The signed payload is the timestamp, a literal ., and the raw JSON request body, joined as one byte string:
Verify by recomputing the HMAC on your side and comparing in constant time. Reject deliveries where the timestamp is older than five minutes. This prevents replay of leaked deliveries.
Make sure your framework gives you the raw request body, not a re-serialized JSON object. Even whitespace differences break the signature.
Most web frameworks auto-parse JSON request bodies and discard the original bytes. The HMAC is computed over the literal bytes Paid sent, so a parsed-and-re-serialized object will not match. Below are minimal recipes for opting into raw-body access on the most common frameworks.
Express (Node): use express.raw() on the webhook route instead of express.json().
Next.js (App Router): use request.text(), not request.json().
Next.js (Pages Router): disable Next’s built-in body parser for the route.
Flask (Python): call request.get_data(), not request.get_json().
FastAPI (Python): await request.body() directly.
Django (Python): request.body is already the raw bytes. Exempt the route from CSRF since Paid is not the user’s browser.
Go (net/http): read the request body before decoding.
The general rule: if your framework auto-parses JSON, find the option to disable it on this route, or read the body before any parser runs.
There is one signing secret per organization. Every webhook delivery from your account is signed with the same key. Rotate from the UI (Settings > Webhooks > Rotate signing secret) or the API:
The response includes signingSecret exactly once. Store it before closing the response. Paid does not store it in a way you can read back.
Rotation invalidates the old secret on the next delivery. Update your receiver before rotating in production, or accept a short verification gap.
Each webhook event is configured independently. In the Paid UI you can:
Paid sends webhook requests as HTTP POST calls. Your endpoint should:
Content-Type: application/json2xx response quickly after validating and enqueueing workYou can manage webhooks programmatically through the v2 API with an organization API key.
Test deliveries only work after the webhook has a valid URL configured and
enabled is set to true.
Field notes:
payment.status is currently postedpayment.invoiceId and payment.invoiceNumber can be null when the payment is not linked to an invoicepayment.externalPaymentId can be null when there is no upstream processor referencepaymentDate is the effective payment timestamp in RFC 3339 formatField notes:
payment.status is currently failedfailureReason is a human-readable failure message when one is availablefailureDate is when Paid recorded the failed attempt in RFC 3339 formatinvoiceId, invoiceNumber, and externalPaymentId can be nullField notes:
remainingCredits is 0 when this event firescreditsCurrencyId can be null if the depleted balance is not scoped to a credits currencysignalId is the Paid signal that pushed the balance to zeroeventName is the original signal event name that consumed the final creditsField notes:
eventType is one of OverageUsage or OverageCreditthreshold is the included usage limit that was crossedcurrentUsage is the usage value observed when the overage condition was detectedcustomerId, customerName, planName, threshold, and currentUsage can be null in edge casesBefore you enable a webhook in Paid, make sure your receiver:
POST requests from Paid at a stable public URLeventnull values for optional fields like invoiceId, invoiceNumber, externalPaymentId, customerName, planName, threshold, and currentUsage2xx after persisting or queueing the eventisTest: true deliveries in your downstream systemsevent values for forward compatibility as new events are addedUse the Test button in Settings > Webhooks to send a synthetic event to your receiver.
isTest is set to truepay_test_*, cust_test_*, ola_test_*, or plan_test_*