Webhooks
The nine events Toolaby sends your server, their payloads, and how to verify a delivery.
Register an endpoint from the terminal — npx -y toolaby@latest webhooks add <url> --env-file .env writes its signing secret into .env and never prints it — or under Configure → Webhooks, 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, and says how many a day's limit held back: those are kept and sent once the day has room again, on Live and on Test, each workspace's oldest first, and may arrive after newer events (Order); the numbers are in Limits. 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 nine below. |
created | integer | Epoch seconds: when the fact was reported, also for an event sent later because a day's limit held it. |
livemode | boolean | false from Test. |
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.
Order
Events are not always delivered in the order they happened. A retried delivery can arrive after newer events, and so can an event a day's limit held back: it is kept and sent once the day has room again, while newer events of the same workspace may already have gone — a refund can arrive before the sale it refunds. Its created is still when the fact was reported. Apply events by created, not by arrival, or take each as a signal to read the customer's current state from the API (Retrieve a customer).
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.TOOLABY_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 full id, as Tool id on the tool's page shows it. plan is the plan's id, as each plan's card under Pricing shows it: default for the tool's first plan, else lifetime, monthly or yearly, with a suffix when that one is taken. user_id is the buyer's account id in your workspace; a licence's purchase.completed has none.
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 buyer's address: the one given at checkout for a licence, the account's for a subscription. |
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. A subscription is always linked. |
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. A licence carries none of them. |
subscription.started
A subscription began. Sent alongside purchase.completed. Neither is sent for a subscription moved from another Stripe account: it was bought before (Move subscriptions).
| Field | Type | |
|---|---|---|
tool | string | |
email | string | null | The buyer's address. |
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.renewed
A subscription was paid for another period: a renewal, never the first payment, which purchase.completed and subscription.started report. Sent once for each paid invoice.
| Field | Type | |
|---|---|---|
tool email subscription plan user_id | As above. | |
invoice | string | Stripe's id of the invoice that was paid. |
amount currency | integer string | What was paid. |
period_end | string | null | When the period just paid for ends. |
subscription.cancelled
A subscription was set to end, its end moved, or it ended. Sent when it is set to end (it runs until ends_at), again when that end moves, and when it ends: at the period's end, on a failed payment, or on a cancellation from the dashboard with now. Any other change, such as a card updated or a period renewed, sends nothing. Before it ends, it is sent at most once a day (UTC): set to end, kept and set to end again on one day is one event.
| Field | Type | |
|---|---|---|
tool email subscription plan status period_end user_id | As above. status is still active while it runs out, or trialing in a trial. | |
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.failed
A subscription's payment failed. Stripe tries again as your account's retry settings say, and each try that fails sends this event again. The subscription becomes past_due and its buyer keeps access until Stripe gives up, when subscription.cancelled follows.
| Field | Type | |
|---|---|---|
tool email subscription plan user_id | As above. | |
invoice | string | Stripe's id of the invoice that was not paid. |
amount currency | integer string | What was due. |
attempt | integer | How many times Stripe has tried this payment. |
next_attempt | string | null | When Stripe tries again. null when it will not. |
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. A subscription whose payment is disputed runs on: see Subscriptions.
| Field | Type | |
|---|---|---|
tool email licence payment_intent | As above. When the disputed payment was a subscription's, licence is an empty string and email is the invoice's address, which can be empty. | |
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 or lost. Won, the licence is active again; lost, it is revoked. An inquiry that closes without becoming a chargeback (warning_closed on Stripe) sends no dispute.closed; its licence is active again, unless it was refunded while the inquiry was open.
| Field | Type | |
|---|---|---|
tool email licence payment_intent dispute reason respond_by | As above. | |
status | string | won or lost. |
amount currency | integer string |
device.uninstalled
A device that held a licence or a running subscription removed the extension, and Chrome opened the page Toolaby shows after a removal (Analytics). Sent when the page opens, with reason set to null, and again when the person answers. Every other removal is under Analytics → Uninstalls and in its files. Sent where your plan holds Uninstalls.
| Field | Type | |
|---|---|---|
tool | string | |
device | string | The device's id, as the customer pages show it. |
version | string | null | The extension's version on the device. |
paying | boolean | Always true: the device held a licence or a running subscription when it was removed. |
plan | string | null | The plan it held. |
days_used | integer | null | Days from the first time the device met the tool. |
reason | string | null | not_needed, not_working, too_expensive, missing_feature, found_another, privacy or other. null until the person answers. |
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 reads the example from Toolaby, so it signs you in first when this machine has no session. It 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 TOOLABY_WEBHOOK_SECRET. Or pass your own with --secret whsec_…. npx -y toolaby@latest webhooks events lists the nine events. A deployed endpoint uses its own secret, under Reveal on the endpoint, never a development one.
A complete endpoint in one file with no dependencies, which verifies, deduplicates by event id and handles all nine: 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 Toolaby 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, or with --env-file .env writes it there as TOOLABY_WEBHOOK_SECRET instead; endpoints lists them, remove <id> removes one. webhooks events --json prints every event with an example of what it carries.
On Test every event is real with livemode: false; a checkout paid with 4242 4242 4242 4242 sends a real purchase.completed.