Toolaby Wall
Reference

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
idstringOne per fact. The same fact reported from Stripe and from the dashboard carries one id. Use it for idempotency.
typestringOne of the six below.
createdintegerEpoch seconds.
livemodebooleanfalse from the test Wall.
dataobjectThe 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.

FieldType
toolstring
kind'licence' | 'subscription'
planstring
emailstring | nullThe address at checkout. null for a subscription; see user_id.
amount currencyinteger | null string | nullWhat was paid. null for a subscription.
payment_intent checkout_sessionstring | nullStripe'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.
seatsinteger?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_idstring? string? string? string?For a subscription: the same facts as subscription.started.

subscription.started

A subscription began. Sent alongside purchase.completed.

FieldType
toolstring
subscriptionstringStripe's id.
planstring
statusstringStripe's: active, trialing.
period_endstring | nullWhen the paid period ends.
user_idstring | null
seatsinteger?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.

FieldType
tool subscription plan status period_end user_idAs above. status is still active while it runs out.
ends_atstring | nullWhen access ends.
immediatelybooleanIt already has.
seatsinteger?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.

FieldType
toolstring
emailstring
licencestringThe licence's id, as the Customers page shows it.
payment_intentstring
amount currencyinteger stringWhat was refunded.

payment.disputed

A buyer disputed a payment with their bank. The licence is suspended while the dispute is open.

FieldType
tool email licence payment_intentAs above. email and licence are null when the disputed payment was a subscription's.
disputestringStripe's id.
reasonstringStripe's: fraudulent, product_not_received, …
statusstringStripe's: needs_response, under_review, …
respond_bystring | nullThe evidence deadline.
amount currencyinteger string

dispute.closed

The dispute closed. Won, the licence is active again; lost, it is revoked.

FieldType
tool email licence payment_intent disputeAs above.
statusstringwon, lost, or warning_closed (an inquiry that went no further; the licence is active again, as for won).
amount currencyinteger 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/webhooks

The 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.

On this page