# Sync customers

> Keep a table of who holds your tool on your own server, with webhooks and the API.

Your server may need to know who holds a tool: to unlock a web app the extension pairs with, to send onboarding email, or to reconcile with a CRM. This guide keeps a customers table current with webhooks, and corrects it with the API.

## Before you begin

* An API key from **Configure → API**, and the API's address shown there: `https://<workspace>.toolaby.app/api/v1` on Live, your workspace's Test address with `/test/api/v1` on Test.
* A server that can receive HTTPS requests. For development, `npx -y toolaby@latest webhooks trigger` reaches a server on your machine; see [Webhooks](https://toolaby.app/docs/reference/webhooks.md#testing-locally).

## 1. Load the table

Page through the tool's customers once and store each.

```js
const base = 'https://northwind.toolaby.app/api/v1'; // on Test: https://quiet-otter-4172.toolaby.app/test/api/v1
let cursor = null;
do {
  const url = new URL(`${base}/tools/${tool}/customers`);
  url.searchParams.set('limit', '100');
  if (cursor) url.searchParams.set('cursor', cursor);
  const page = await (await fetch(url, { headers: { Authorization: `Bearer ${key}` } })).json();
  for (const c of page.data) await db.customers.upsert({ email: c.email, entitled: c.entitled, via: c.via });
  cursor = page.next_cursor;
} while (cursor);
```

## 2. Keep it current with webhooks

Register your endpoint, with every event: `npx -y toolaby@latest webhooks add https://your-server.example/webhooks/toolaby --env-file .env` registers it and writes its signing secret into `.env` as `TOOLABY_WEBHOOK_SECRET` (or **Configure → Webhooks** in the dashboard). A coding agent does it on Test as part of the job, with the `toolaby_add_webhook` tool. Until it is registered, no real event reaches your server. On each delivery, verify the signature, then apply the change:

| Event                    | Change                                                                                                                                                                                                            |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `purchase.completed`     | `entitled: true`, `via` from `kind` (`licence` or `subscription`), for the row of its `email`. A licence bought signed out (`account: 'none'`) has no account yet: its email address is the only way to match it. |
| `subscription.started`   | `entitled: true`, `via: 'subscription'`, for the row of its `email`.                                                                                                                                              |
| `subscription.cancelled` | For the row of its `email`: with `immediately: true`, `entitled: false`. Otherwise access continues until `ends_at`: keep `entitled: true` and record the date.                                                   |
| `payment.refunded`       | `entitled: false` for that licence.                                                                                                                                                                               |
| `payment.disputed`       | For a licence: `entitled: false` while the dispute is open. With `licence` empty, the payment was a subscription's, which runs on: nothing changes.                                                               |
| `dispute.closed`         | For a licence, `won`: `entitled: true`; `lost`: `entitled: false`. An inquiry that closes without a chargeback sends no event: step 3 corrects it.                                                                |

Store the event `id` and ignore a delivery whose id you have seen. Answer 2xx before writing to the database.

Grants and trials made in the dashboard or by the API are not events, and neither is a subscription's failed payment, which unlocks nothing while it is past due. Step 3 covers them. See [Subscriptions](https://toolaby.app/docs/subscriptions.md).

## 3. Correct with the API

Webhooks tell you what changed; the API tells you what is. Once a day, or before an action that matters, retrieve the customer:

```js
const c = await (await fetch(`${base}/tools/${tool}/customers/${encodeURIComponent(email)}`, {
  headers: { Authorization: `Bearer ${key}` },
})).json();
await db.customers.upsert({ email, entitled: c.entitled, via: c.via });
```

`entitled` is the truth at that moment, including grants, trials and bundles. A workspace makes 20,000 lookups a day; to correct more customers than that, page through the list as in step 1.

## Notes

* A team licence entitles the seat holders, not the buyer; each holder appears as a customer with `via: 'licence'`.
* A buyer who changes their address keeps their account; the API answers for the new address.
* Deliveries can arrive out of order. When two events concern the same subscription, the later `created` wins.
