Webhooks
Reacting to invoices, subscriptions and usage — and verifying they are real.
Verify before you trust
import { constructWebhookEvent } from "@base316/sdk";
app.post("/webhooks/base316", async (request, response) => {
// The RAW bytes. Not a re-serialised object.
const payload = await readRawBody(request);
const event = await constructWebhookEvent({
payload,
signature: request.headers["base316-signature"],
secret: process.env.BASE316_WEBHOOK_SIGNING_KEY,
});
// ... handle event.type ...
response.status(200).end();
});Verify the raw bytes, not a parsed and re-serialised object. Any body parser that has already turned the JSON into an object and back has changed the bytes — key order, spacing, number formatting — and the signature will not match.
The signing key comes from the endpoint's row in the dashboard's Webhooks tab. The signature is compared in constant time and carries a timestamp, so a captured request cannot be replayed indefinitely.
Billing events
| Event | When |
|---|---|
customer.created | An account was opened |
subscription.created · .updated · .canceled | A subscription changed |
entitlement.updated | What an account may do changed |
usage.limit_reached | An account hit an allowance |
credits.low_balance | A metered balance fell below its threshold |
credit_pack.purchased | A credit pack was paid for and granted |
credits.auto_recharged | Auto-recharge bought a pack and granted it |
credits.auto_recharge_failed | Auto-recharge could not buy a pack |
invoice.created · .finalized · .paid · .voided · .uncollectible | Invoice lifecycle |
payment.recorded | A payment was received, including a part payment |
payment.failed | A payment failed or was reversed |
credit_note.issued | An invoice was partly written off |
refund.created | A credit note issued a refund |
refund.succeeded | A refund reached the customer |
refund.failed | A refund failed; the money is still owed |
wallet.credited · .debited | Prepaid balance moved |
agreement.created · .sent · .viewed · .signed · .card_added | An agreement offer moved; see Agreement checkout |
agreement.activated · .revoked · .expired · .imported | An agreement ended, or one was imported |
Why a subscription changed
subscription.updated carries a change saying why:
change | What happened |
|---|---|
renewed | A period ended and the next one began. When a scheduled change or ramp step took effect there, planChange names it and planVersionId is the new plan (was previousPlanVersionId) |
plan_changed | Moved to another plan; previousSubscriptionId is the one that closed |
quantity_changed | previousQuantity → quantity, from the next invoice |
cancel_scheduled | Ends at the period end; still entitled until then. cancellationId and cancellationReason when it came through a cancel flow |
trial_ended | A trial ran out and converted: ACTIVE on its own plan, first period billed |
trial_downgraded | A trial on a plan whose trial-end action is DOWNGRADE ran out with no payment method on file. Same subscription, now ACTIVE on planVersionId (was previousPlanVersionId); nothing invoiced |
plan_change_scheduled | A plan change was scheduled for the period end. planVersionId and effectiveAt say what and when; nothing changes until then |
phases_scheduled | A schedule of phases (a ramp) was set. phases lists each with the boundary it takes effect at |
schedule_canceled | The pending schedule was withdrawn — undone, or replaced by an empty one |
price_overrides_changed | A custom price was added, edited or removed; the next invoice is rated on the new terms |
contract_updated | The PO number, invoice memo or billing day changed; previousBillingAnchorDay when the day did |
retention_offer_accepted | A subscriber who set out to cancel took the retention offer; discountId is the discount applied, resumed says a scheduled cancellation was lifted |
trial_downgraded is the one to act on for a reverse trial: switch the account
to the free tier in your own product, and invite the customer to add a card to
come back.
{
"subscriptionId": "sub_…",
"customerId": "cus_…",
"change": "trial_downgraded",
"previousPlanVersionId": "pv_team…",
"planVersionId": "pv_free…",
"currentPeriodStart": "2026-10-16T09:00:00.000Z",
"currentPeriodEnd": "2026-11-16T09:00:00.000Z"
}payment.recorded fires for every receipt including instalments;
invoice.paid fires only when the balance reaches zero. If you are reconciling
your own ledger you want the first.
Both fire regardless of how the money arrived — a card through a payment link, a
renewal charged to a stored card, cash or a cheque entered by hand. So a handler
that releases a job when invoice.paid arrives works the same whether the
customer tapped a phone at the counter or paid on terms three weeks later.
Knowing when a walk-up customer has paid
A customer scans a QR code and pays on their own phone. Two things usually need to hear about it, and they are not the same thing:
Your server wants invoice.paid — release the job, print the ticket, close
the work order. That is the webhook above, and it is the one to build on.
The screen the code is showing on cannot receive a webhook. Poll
GET /pay/{environmentId}/{token}/status instead, with the token you got back
from invoices.createPaymentLink:
{ "state": "payable", "number": "INV-2026-000042", "due": "250.00", "currency": "USD" }It answers settled once there is nothing left owing. In React, the
usePaymentLink hook does this for you and stops polling on its own:
const status = usePaymentLink({ token, environmentId, onPaid: () => closeTicket() });
if (status.state === "settled") {
return <p>Paid — thank you.</p>;
}Poll for the screen, not for the ledger. The status endpoint reports what the invoice owes right now; the webhook is the record that a payment happened. A handler that only polls will miss a payment made while nobody had the page open.
Identity events
Most applications keep their own users row alongside Base316's. These are how
you keep it current without polling.
| Event | When |
|---|---|
user.created | A user was created through the API or dashboard |
user.updated | A user's record changed |
user.disabled · .enabled | A user was switched off or back on |
user.signed_up | Somebody signed themselves up |
user.signed_in · .signed_out | A session began or ended |
user.sign_in_failed | A sign-in was refused |
user.password_changed | A password was set or changed |
user.password_reset_requested · .password_reset_completed | Reset lifecycle |
user.mfa_enrolled · .mfa_verified · .mfa_failed · .mfa_disabled | Second-factor lifecycle |
user.recovery_code_used | A recovery code was spent |
user.oauth_linked | A social account was linked |
user.impersonation_started | An operator began acting as a user |
session.revoked | A session was ended by somebody other than its owner |
organization.created | An organization was created |
organization.member_added | Somebody joined, or their role changed |
organization.member_removed | Somebody left |
organization.detached | A managed tenant was made standalone |
organization.deleted | An organization was deleted |
user.created covers the administrative path only — the API, an import, the
dashboard. Somebody signing themselves up raises user.signed_up instead, so an
endpoint watching for new accounts is not woken twice for one person. Subscribe
to both if you want every arrival.
organization.member_added also fires when an existing member's role changes.
The payload states what the membership now is, so applying it twice is harmless.
organization.created carries organization.parentId — the tenant that manages
the new one, or null for a standalone tenant. organization.detached carries
previousParentId; the parent's members lost access to the tenant when it was
sent. See Managed tenants.
organization.deleted carries the tenant as it was (organization.id, name,
externalId, parentId), the billing account that was unlinked and kept
(customerId, or null), any trial the delete cancelled
(canceledSubscriptionIds, each also sent as subscription.canceled with
reason: "organization_deleted") and who was removed with it
(removedMemberUserIds, each also sent as organization.member_removed). A
repeated delete answers alreadyDeleted: true and sends nothing.
A sign-up that accepted an invitation sends organization.member_added with
viaInvitation, the same as accepting it after signing up — and no
organization.created, because no personal organization was opened.
user.sign_in_failed is emitted for every refused attempt, including ones against an address that
has no account — in which case userId is null and the attempted email is on the payload.
Treat it as a security signal, not as evidence that a user exists.
User payloads carry the whole record rather than a diff, so your handler can
upsert. metadata is deliberately left out — it is arbitrary JSON of unbounded
size, and a webhook body is not where you want to discover that. Read the user
if you need it.
Handling them well
Return 200 quickly. Do the work afterwards. A slow handler is retried, and a retried handler that is not idempotent does the work twice.
Expect redelivery. Events are retried with backoff. Key your handling on the event id so a redelivery is a no-op.
Do not assume order. invoice.paid can arrive before invoice.finalized
under retry. Treat each event as a statement of fact about a resource, and
re-read the resource if order matters.
Testing
Under Developers → Webhooks, the Deliveries tab shows every delivery and the response it got. The Events tab lists recent events, newest first, with each endpoint's delivery status. It shows the payload and can replay an event, either to every subscriber or to only the endpoint whose handler you fixed. Replaying is much faster for debugging a handler than causing the real event again.
The API does the same:
// Every enabled subscriber again, or only the one that failed.
await base316.webhooks.replay("evt_...");
await base316.webhooks.replayTo("evt_...", "whe_...");Changing an endpoint
You can change an endpoint's URL, description and event types without
recreating it, in the dashboard or with PATCH /v1/webhooks/endpoints/{id}
(base316.webhooks.update). Fields you leave out keep their values, and an
empty eventTypes subscribes to every event. The signing secret does not
change, so your receiver keeps verifying with the secret it already has. A new
URL goes through the same check as one at creation: https only, and it must
resolve to a public host.
Text messages and phone numbers
Signing in with a code sent by SMS, proving a phone number, and SMS as a second factor — sent from Base316's number or your own Twilio account.
Machine authentication without secrets
Let services authenticate with signed assertions (private_key_jwt), let Kubernetes pods and CI jobs use the token their platform already gives them, and hold clients to OAuth 2.1.

