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/v1on Live, your workspace's Test address with/test/api/v1on Test. - A server that can receive HTTPS requests. For development,
npx -y toolaby@latest webhooks triggerreaches a server on your machine; see Webhooks.
1. Load the table
Page through the tool's customers once and store each.
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.
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:
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
createdwins.