Base 3:16
Guides

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

EventWhen
customer.createdAn account was opened
subscription.created · .updated · .canceledA subscription changed
entitlement.updatedWhat an account may do changed
usage.limit_reachedAn account hit an allowance
credits.low_balanceA metered balance fell below its threshold
credit_pack.purchasedA credit pack was paid for and granted
credits.auto_rechargedAuto-recharge bought a pack and granted it
credits.auto_recharge_failedAuto-recharge could not buy a pack
invoice.created · .finalized · .paid · .voided · .uncollectibleInvoice lifecycle
payment.recordedA payment was received, including a part payment
payment.failedA payment failed or was reversed
credit_note.issuedAn invoice was partly written off
refund.createdA credit note issued a refund
refund.succeededA refund reached the customer
refund.failedA refund failed; the money is still owed
wallet.credited · .debitedPrepaid balance moved
agreement.created · .sent · .viewed · .signed · .card_addedAn agreement offer moved; see Agreement checkout
agreement.activated · .revoked · .expired · .importedAn agreement ended, or one was imported

Why a subscription changed

subscription.updated carries a change saying why:

changeWhat happened
renewedA 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_changedMoved to another plan; previousSubscriptionId is the one that closed
quantity_changedpreviousQuantity → quantity, from the next invoice
cancel_scheduledEnds at the period end; still entitled until then. cancellationId and cancellationReason when it came through a cancel flow
trial_endedA trial ran out and converted: ACTIVE on its own plan, first period billed
trial_downgradedA 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_scheduledA plan change was scheduled for the period end. planVersionId and effectiveAt say what and when; nothing changes until then
phases_scheduledA schedule of phases (a ramp) was set. phases lists each with the boundary it takes effect at
schedule_canceledThe pending schedule was withdrawn — undone, or replaced by an empty one
price_overrides_changedA custom price was added, edited or removed; the next invoice is rated on the new terms
contract_updatedThe PO number, invoice memo or billing day changed; previousBillingAnchorDay when the day did
retention_offer_acceptedA 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.

EventWhen
user.createdA user was created through the API or dashboard
user.updatedA user's record changed
user.disabled · .enabledA user was switched off or back on
user.signed_upSomebody signed themselves up
user.signed_in · .signed_outA session began or ended
user.sign_in_failedA sign-in was refused
user.password_changedA password was set or changed
user.password_reset_requested · .password_reset_completedReset lifecycle
user.mfa_enrolled · .mfa_verified · .mfa_failed · .mfa_disabledSecond-factor lifecycle
user.recovery_code_usedA recovery code was spent
user.oauth_linkedA social account was linked
user.impersonation_startedAn operator began acting as a user
session.revokedA session was ended by somebody other than its owner
organization.createdAn organization was created
organization.member_addedSomebody joined, or their role changed
organization.member_removedSomebody left
organization.detachedA managed tenant was made standalone
organization.deletedAn 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.

On this page