Toolaby
Reference

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
idstringOne per fact. The same fact reported from Stripe and from the dashboard carries one id. Use it for idempotency.
typestringOne of the nine below.
createdintegerEpoch seconds: when the fact was reported, also for an event sent later because a day's limit held it.
livemodebooleanfalse from Test.
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.

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.

FieldType
toolstring
kind'licence' | 'subscription'
planstring
emailstring | nullThe buyer's address: the one given at checkout for a licence, the account's for a subscription.
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. A subscription is always linked.
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. 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).

FieldType
toolstring
emailstring | nullThe buyer's address.
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.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.

FieldType
tool email subscription plan user_idAs above.
invoicestringStripe's id of the invoice that was paid.
amount currencyinteger stringWhat was paid.
period_endstring | nullWhen 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.

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

FieldType
tool email subscription plan user_idAs above.
invoicestringStripe's id of the invoice that was not paid.
amount currencyinteger stringWhat was due.
attemptintegerHow many times Stripe has tried this payment.
next_attemptstring | nullWhen 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.

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. A subscription whose payment is disputed runs on: see Subscriptions.

FieldType
tool email licence payment_intentAs 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.
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 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.

FieldType
tool email licence payment_intent dispute reason respond_byAs above.
statusstringwon or lost.
amount currencyinteger 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.

FieldType
toolstring
devicestringThe device's id, as the customer pages show it.
versionstring | nullThe extension's version on the device.
payingbooleanAlways true: the device held a licence or a running subscription when it was removed.
planstring | nullThe plan it held.
days_usedinteger | nullDays from the first time the device met the tool.
reasonstring | nullnot_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/webhooks

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

On this page