Webhooks
The six events the Wall sends your server, their payloads, and how to verify a delivery.
Under Configure → Webhooks, add an endpoint and choose its events. Deliveries are signed as Standard Webhooks, retried on a schedule for days when your server does not answer 2xx, and logged on the endpoint's page: every delivery with its attempts and your server's answer, Resend for one, Recover failed for everything that failed since an hour, a day, a week or a month ago. Configure → Events lists everything emitted, whether or not an endpoint was listening. Delivery runs on Svix, in the EU.
The delivery
POST /your/endpoint
content-type: application/json
webhook-id: msg_2f8Qm…
webhook-timestamp: 1758196423
webhook-signature: v1,K5oZ…
{
"id": "evt_purchase_completed_cs_test_b19QQ",
"type": "purchase.completed",
"created": 1758196423,
"livemode": true,
"data": { … }
}| Field | ||
|---|---|---|
id | string | One per fact. The same fact reported from Stripe and from the dashboard carries one id. Use it for idempotency. |
type | string | One of the six below. |
created | integer | Epoch seconds. |
livemode | boolean | false from the test Wall. |
data | object | The event's own facts, below. |
Answer 2xx within a few seconds and do the work after. Any other answer, or a timeout, is retried.
Verifying
The endpoint's signing secret is shown when it is added, and under Reveal on the endpoint after. With the svix package or any Standard Webhooks library:
import { Webhook } from 'svix';
export async function POST(req: Request) {
const body = await req.text(); // the raw body, not parsed
const event = new Webhook(process.env.WALL_WEBHOOK_SECRET).verify(body, {
'webhook-id': req.headers.get('webhook-id')!,
'webhook-timestamp': req.headers.get('webhook-timestamp')!,
'webhook-signature': req.headers.get('webhook-signature')!,
}); // throws when the signature is wrong, or the timestamp older than five minutes
…
}Events
Money is in minor units of currency (1000 is €10.00). Dates are ISO strings. tool is the tool's id; plan is <tool>:<plan id>, or the bare id for the default plan; user_id is the buyer's id in your workspace, null for a purchase made without an account.
purchase.completed
A checkout was paid: a licence was created, or a subscription began.
| Field | Type | |
|---|---|---|
tool | string | |
kind | 'licence' | 'subscription' | |
plan | string | |
email | string | null | The address at checkout. null for a subscription; see user_id. |
amount currency | integer | null string | null | What was paid. null for a subscription. |
payment_intent checkout_session | string | null | Stripe's ids, for a licence. |
account | 'linked' | 'none' | Made signed in or not. A licence bought signed out attaches to the account that later verifies the address. |
seats | integer? | Bought for a team: the number of seats — a team licence, or a team subscription. The buyer hands them out from their account. |
subscription status period_end user_id | string? string? string? string? | For a subscription: the same facts as subscription.started. |
subscription.started
A subscription began. Sent alongside purchase.completed.
| Field | Type | |
|---|---|---|
tool | string | |
subscription | string | Stripe's id. |
plan | string | |
status | string | Stripe's: active, trialing. |
period_end | string | null | When the paid period ends. |
user_id | string | null | |
seats | integer? | A team subscription: the seats it holds. It entitles its buyer to nothing by itself; each holder has a licence of their own. |
subscription.cancelled
A subscription was set to end, or ended. Sent when the buyer cancels (it runs until ends_at), and again when it ends: at the period's end, on a failed payment, or on a cancellation from the dashboard with now. Once a day: set to cancel, back, and to cancel again within one day (UTC) — its end or its period moved or not — it is sent once; set to cancel again on a later day, it is sent again.
| Field | Type | |
|---|---|---|
tool subscription plan status period_end user_id | As above. status is still active while it runs out. | |
ends_at | string | null | When access ends. |
immediately | boolean | It already has. |
seats | integer? | A team subscription: the seats it held. Every holder loses theirs when it ends. |
payment.refunded
A payment was refunded in full, from Stripe or from the dashboard. The licence is revoked; the extension stops at its next check. A partial refund sends no event: the licence stands.
| Field | Type | |
|---|---|---|
tool | string | |
email | string | |
licence | string | The licence's id, as the Customers page shows it. |
payment_intent | string | |
amount currency | integer string | What was refunded. |
payment.disputed
A buyer disputed a payment with their bank. The licence is suspended while the dispute is open.
| Field | Type | |
|---|---|---|
tool email licence payment_intent | As above. email and licence are null when the disputed payment was a subscription's. | |
dispute | string | Stripe's id. |
reason | string | Stripe's: fraudulent, product_not_received, … |
status | string | Stripe's: needs_response, under_review, … |
respond_by | string | null | The evidence deadline. |
amount currency | integer string |
dispute.closed
The dispute closed. Won, the licence is active again; lost, it is revoked.
| Field | Type | |
|---|---|---|
tool email licence payment_intent dispute | As above. | |
status | string | won, lost, or warning_closed (an inquiry that went no further; the licence is active again, as for won). |
amount currency | integer string |
Testing locally
Send an example of any event to a server on your machine, signed, with no endpoint registered and no tunnel:
npx -y toolaby@latest webhooks trigger purchase.completed --to http://localhost:4000/webhooksThe command signs the delivery with your machine's own development secret, made the first time it is needed; npx -y toolaby@latest webhooks secret prints it for your server's WALL_WEBHOOK_SECRET. Or pass your own with --secret whsec_…. npx -y toolaby@latest webhooks events lists the six events. A deployed endpoint uses the secret the portal shows, never a development one.
A complete endpoint in one file with no dependencies, which verifies, deduplicates by event id and handles all six: webhook-server.zip. Its README runs both commands.
Testing a registered endpoint
npx -y toolaby@latest webhooks trigger purchase.completed (without --to) sends the example through the Wall to every endpoint registered under Configure → Webhooks: signed with the endpoint's own secret, logged, retried. Send a test event on the endpoint does the same from the dashboard. A workspace sends 100 examples a day, from both together; past that, both refuse until the next day. npx -y toolaby@latest webhooks add <url> registers an endpoint from the terminal and prints its secret once; endpoints lists them, remove <id> removes one.
On the test Wall every event is real with livemode: false; a checkout paid with 4242 4242 4242 4242 sends a real purchase.completed.