# 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](https://www.standardwebhooks.com), 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](#order)); the numbers are in [Limits](https://toolaby.app/docs/limits.md#fair-use). Delivery runs on [Svix](https://www.svix.com), in the EU.

## The delivery

```http
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](https://toolaby.app/docs/reference/api.md#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:

```ts
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](https://toolaby.app/docs/move-subscriptions.md)).

| 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](https://toolaby.app/docs/subscriptions.md).

| 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](https://toolaby.app/docs/analytics.md#the-uninstall-page)). 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:

```bash
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](/examples/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`.
