# API

> Look up whether a person holds a tool, grant or revoke access, count a signed-in buyer's uses, and read a tool's Analytics, from your own server.

The API is REST over HTTPS, at your workspace's address. Requests carry an API key as a bearer token; bodies and responses are JSON. The OpenAPI document is at `/api/v1/openapi.json`, and a generated reference to read at [toolaby.app/api/v1/docs](https://toolaby.app/api/v1/docs).

```
https://<workspace>.toolaby.app/api/v1
```

On Test, the base is your workspace's Test address with `/test`, like `https://quiet-otter-4172.toolaby.app/test/api/v1`. **Configure → API** shows yours on each side.

## Authentication

Create a key under **Configure → API** in the dashboard. It is shown once. Test keys (`wk_test_…`) act on Test's data; live keys (`wk_live_…`) on Live's.

```bash
curl https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers/maya@example.com \
  -H "Authorization: Bearer wk_live_…"
```

```js
const res = await fetch('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers/maya@example.com', {
  headers: { Authorization: `Bearer ${process.env.TOOLABY_API_KEY}` },
});
const customer = await res.json();
```

There is no SDK; the API is nine endpoints, called with `fetch`. A key acts only on tools in its workspace. A tool in another workspace, or one that does not exist, answers `404`.

`{tool}` in a path is either of the tool's ids: the full one, as **Tool id** on the tool's page shows it (`northwind--invoice-grabber`), or the short one (`invoice-grabber`). Responses name the tool by its full id.

## Rate limits

600 requests a minute per key and 1,200 per workspace, all its keys together, in the clock's minute (UTC). Above either, `429` with `rate_limit_error`.

* **List customers**: 30 pages a minute per workspace, all its keys together. Each page reads the tool's whole list, and the rows it reads count toward the workspace's 300,000 a day, the dashboard's **Customers** page with them: ask for 500 a page to read a long list in few. See [Limits](https://toolaby.app/docs/limits.md).
* **Retrieve a customer**: 20,000 a day per workspace, all its keys together, counted before it reads. **List grants** is a lookup by address too, counted with it. The day is the 24 hours from the first lookup in it. To know every customer, page through the list; to keep up, use webhooks: see [Sync customers](https://toolaby.app/docs/sync.md).
* **Uses**: each buyer's asks are counted per minute, and their free uses per day, as an extension's are: see [Limits](https://toolaby.app/docs/limits.md).
* **Create a grant**: 1,000 a day per tool, counted over the last 24 hours, however the grants were made. Past it, the API and the command line refuse. Each grant is also one of the workspace's 20,000 additions a day, counted before it is written: see [Limits](https://toolaby.app/docs/limits.md). Bring more in with the tool's **Import** page.

## Errors

Every error has the shape:

```json
{ "error": { "type": "invalid_request_error", "message": "days: Too big: expected number to be <=3650", "param": "days" } }
```

| Status | `type`                  |                                                                                                                                                                                                                                                                                                          |
| ------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request_error` | A parameter is missing or wrong; `param` names it.                                                                                                                                                                                                                                                       |
| 400    | `idempotency_error`     | An `Idempotency-Key` first used for another count: an action of its own needs a key of its own.                                                                                                                                                                                                          |
| 401    | `authentication_error`  | No key, a malformed key, a revoked one, or one whose workspace's owners are suspended. For uses: a site token that is not valid for the tool, or has ended.                                                                                                                                              |
| 403    | `plan_limit_error`      | The workspace's plan does not allow it. On Hobby a workspace holds 100 passes at once: [Plans](https://toolaby.app/docs/plans.md). On Test a workspace holds 100 people, whatever its plan: [Test and Live](https://toolaby.app/docs/test-and-live.md). Analytics in the API, and some of its parts, are on Pro: [Analytics](https://toolaby.app/docs/analytics.md#plans). |
| 403    | `invalid_request_error` | Toolaby switched the tool off. The message says to write to [hello@toolaby.app](mailto:hello@toolaby.app).                                                                                                                                                                                               |
| 404    | `not_found`             | No such tool, grant or person in this workspace.                                                                                                                                                                                                                                                         |
| 409    | `idempotency_error`     | A request with the same `Idempotency-Key` is still being answered.                                                                                                                                                                                                                                       |
| 429    | `rate_limit_error`      | The key's minute is used up, this address sent too many wrong keys, the workspace read 30 pages of customers this minute or its lists' rows for today, it looked up 20,000 customers today, the tool made 1,000 grants in the last 24 hours, or the workspace made its 20,000 additions today.           |
| 500    | `api_error`             | Toolaby's fault, or it cannot take additions just now. Retry.                                                                                                                                                                                                                                            |
| 503    | `api_error`             | Toolaby cannot read or count a buyer's uses just now. Retry.                                                                                                                                                                                                                                             |

## Endpoints

### List customers

`GET /tools/{tool}/customers`

Everyone who holds the tool or has signed in to it, newest first: the newest 2,000 of each kind, as in the dashboard. A customer beyond them answers [Retrieve a customer](#retrieve-a-customer).

| Parameter  |                 |                                                                               |
| ---------- | --------------- | ----------------------------------------------------------------------------- |
| `tool`     | path            | The tool id.                                                                  |
| `limit`    | query, optional | 1 to 500. Default 50.                                                         |
| `cursor`   | query, optional | `next_cursor` from the previous page.                                         |
| `entitled` | query, optional | `true`: only customers who hold the tool now; `false`: only those who do not. |

**Returns** a page.

```json
{
  "data": [
    { "email": "maya@example.com", "account": true, "entitled": true, "via": "subscription", "since": "2026-09-18T13:33:55.000Z", "last_active_at": "2026-09-20T08:12:01.000Z", "devices": 2 }
  ],
  "has_more": true,
  "next_cursor": "NTA"
}
```

```js
let cursor = null;
do {
  const url = new URL('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers?limit=500');
  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 upsertCustomer(c);
  cursor = page.next_cursor;
} while (cursor);
```

### Retrieve a customer

`GET /tools/{tool}/customers/{email}`

Whether the address holds the tool now, and how. An address with no account still answers for a licence bought under it. Each lookup is one of the workspace's 20,000 a day: see [Rate limits](#rate-limits).

| Parameter |      |                                                                                                              |
| --------- | ---- | ------------------------------------------------------------------------------------------------------------ |
| `tool`    | path | The tool id.                                                                                                 |
| `email`   | path | The address, URL-encoded. Another spelling of a Gmail address, with or without dots, finds the same account. |

**Returns** a Customer.

```json
{
  "email": "maya@example.com",
  "account": true,
  "entitled": true,
  "via": "subscription",
  "paid_at": "2026-09-18T09:38:35.000Z",
  "plan": { "id": "yearly", "name": "Yearly", "billing": "subscription", "amountCents": 2900, "currency": "eur", "interval": "year" },
  "subscription": { "status": "active", "periodEnd": "2027-09-18T09:38:35.000Z", "cancelAtPeriodEnd": false, "cancelAt": null },
  "trial": null
}
```

### List grants

`GET /tools/{tool}/grants?email={email}`

The address's grants and trials for the tool, newest first, up to 100.

| Parameter |       |              |
| --------- | ----- | ------------ |
| `tool`    | path  | The tool id. |
| `email`   | query | The address. |

**Returns** `{ "data": [Grant, …] }`.

### Create a grant

`POST /tools/{tool}/grants`

Grants access without payment. An unknown address gets an account; the grant applies when they sign in. The extension unlocks at its next check.

| Parameter |                   |                                                                           |
| --------- | ----------------- | ------------------------------------------------------------------------- |
| `email`   | string, required  | The address, at most 254 characters.                                      |
| `days`    | integer, optional | 1 to 3650. Absent or `0`: until revoked.                                  |
| `note`    | string, optional  | Up to 200 characters, shown in the dashboard. A NUL character is refused. |

```bash
curl -X POST https://northwind.toolaby.app/api/v1/tools/invoice-grabber/grants \
  -H "Authorization: Bearer wk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "email": "maya@example.com", "days": 30, "note": "Beta tester" }'
```

```js
const grant = await (await fetch('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/grants', {
  method: 'POST',
  headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'maya@example.com', days: 30, note: 'Beta tester' }),
})).json();
```

**Returns** the Grant with status `201`. On Hobby, a grant made at the same moment as others can pass the 100 passes: it is made and held, with `live: false`.

### Revoke a grant

`DELETE /tools/{tool}/grants/{id}`

Ends the grant. The extension stops at its next check, within 15 minutes. Revoking twice succeeds.

**Returns** `{ "id", "tool", "revoked_at", "live": false }`.

### Retrieve a buyer's uses

`GET /tools/{tool}/uses`

What the signed-in buyer a site token names holds of the tool, and how many of its free uses are left, counting nothing: for your page to say before the first use. Your server sends its key, and the buyer's site token: `Toolaby.token({ tool })` on your page, or `toolaby.siteToken({ audience })` in your extension ([Your server](https://toolaby.app/docs/web-javascript.md#your-server)).

| Parameter            |        |                                                |
| -------------------- | ------ | ---------------------------------------------- |
| `tool`               | path   | The tool id.                                   |
| `Toolaby-Site-Token` | header | The buyer's site token. It lasts five minutes. |

**Returns** a Usage.

### Count a buyer's uses

`POST /tools/{tool}/uses`

Counts `count` of the buyer's uses, for an action your server does for them. It is allowed and counted, or refused whole, with why (`reason`) and what remains: a refusal is an answer, `200` with `allowed: false`, not an error. A buyer who holds the tool, or a tool open to everyone, counts nothing.

| Parameter            |                  |                                                                                                                                                                                                                                                                                                                                                                                  |
| -------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tool`               | path             | The tool id.                                                                                                                                                                                                                                                                                                                                                                     |
| `Toolaby-Site-Token` | header           | The buyer's site token.                                                                                                                                                                                                                                                                                                                                                          |
| `Idempotency-Key`    | header, optional | 1 to 255 characters naming this one action: make it from what the action counts, never from what the browser sends. A retry with the same key and count answers what the first did and counts nothing more, for 24 hours; the same key with another count is refused, `idempotency_error`. A refusal counts nothing and is not kept: a buyer who pays in between is let through. |
| `count`              | body, optional   | How many uses the action takes: 1 to 1,000, 1 by default.                                                                                                                                                                                                                                                                                                                        |

```bash
curl https://northwind.toolaby.app/api/v1/tools/invoice-grabber/uses \
  -H "Authorization: Bearer wk_live_…" \
  -H "Toolaby-Site-Token: $SITE_TOKEN" \
  -H "Idempotency-Key: $ACTION_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "count": 3 }'
```

**Returns** a Usage.

```json
{ "object": "usage", "allowed": true, "entitled": false, "used": 3, "remaining": 7, "limit": 10, "per": "ever" }
```

### Analytics summary

`GET /tools/{tool}/analytics?range={range}`

The figures of the tool's Analytics Overview for the period: devices active today, in 7 days and in 30 days, new devices; the funnel from a new device to a purchase, which leaves out the devices that have the tool another way and counts them in `has_access_elsewhere`; what was paid and refunded in each currency, with monthly recurring revenue; and uninstalls. Analytics in the API is on Pro ([Analytics](https://toolaby.app/docs/analytics.md#plans)).

| Parameter |                 |                                                                                                                                                                                                                                                   |
| --------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tool`    | path            | The tool id.                                                                                                                                                                                                                                      |
| `range`   | query, optional | `7d`, `30d` (the default), `90d` or `12m`: that many days to today. Hobby sees 30 days back, Pro thirteen months.                                                                                                                                 |
| `channel` | query, optional | `app`, the extension's devices, or `web`, a [web app](https://toolaby.app/docs/analytics.md#web-apps)'s signed-in buyers. Both together where absent. Revenue is the tool's whole either way, and uninstalls are the extension's. The answer says it back as `channel`. |

**Returns** an AnalyticsSummary. A part the plan does not hold is `null`; `parts` says what each part is to your workspace.

```json
{
  "tool": "northwind--invoice-grabber",
  "range": { "id": "30d", "from": "2026-09-17", "to": "2026-10-16", "days": 30, "clamped": false },
  "counting": true,
  "parts": { "overview": "open", "revenue": "open", "devices": "open", "funnel": "open", "features": "open", "uninstalls": "open", "versions": "open", "experiments": "off", "export": "open", "history": "open" },
  "devices": { "active": 1163, "active_7_days": 2775, "active_30_days": 5202, "new": 3401 },
  "funnel": { "new_devices": 3363, "met_paywall": 742, "opened_checkout": 210, "paid": 99, "median_days_to_paid": 2, "has_access_elsewhere": 38 },
  "revenue": [{ "currency": "eur", "purchased": 412300, "renewed": 25100, "refunded": 0, "net": 437400, "mrr": 26400, "subscriptions": 88 }],
  "uninstalls": { "removed": 234, "paying": 3, "answered": 141, "median_days_kept": 9 }
}
```

### Analytics table

`GET /tools/{tool}/analytics/{table}?range={range}`

One table of the tool's Analytics as rows, the CSV file's own: each row an object keyed by its columns. A table is open where its part is.

| Parameter |                 |                                                                                                                                    |
| --------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `tool`    | path            | The tool id.                                                                                                                       |
| `table`   | path            | `devices` and `cohorts` (Devices), `revenue` (Revenue), `features` (Features), `versions` (Versions) or `uninstalls` (Uninstalls). |
| `range`   | query, optional | As for the summary.                                                                                                                |
| `channel` | query, optional | As for the summary.                                                                                                                |

**Returns** `{ "part", "range", "channel", "data": [ … ] }`.

```json
{
  "part": "features",
  "range": { "id": "30d", "from": "2026-09-17", "to": "2026-10-16", "days": 30, "clamped": false },
  "data": [
    { "opened_by": "_limit", "paywall_opens": 6512, "checkouts": 98, "purchases": 72, "currency": "eur", "revenue": 216000 },
    { "opened_by": "export", "paywall_opens": 1349, "checkouts": 21, "purchases": 15, "currency": "eur", "revenue": 45000 }
  ]
}
```

| Table        | Columns                                                                                                                                                     |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `devices`    | `day`, `active`, `active_7_days`, `active_30_days`, `new_devices`, `met_paywall`, `checkouts`                                                               |
| `cohorts`    | `week`, `new_devices`, and for days 1, 7 and 28: `day_N_due` (devices that met the tool that long ago) and `day_N_back` (of them, active that day)          |
| `revenue`    | `day`, `currency`, `purchases`, `purchased`, `renewals`, `renewed`, `refunds`, `refunded`, `mrr`, `subscriptions`, `trialing`                               |
| `features`   | `opened_by` (a feature's key, `_limit` for free uses spent, `_paid` for a paid-only tool), `paywall_opens`, `checkouts`, `purchases`, `currency`, `revenue` |
| `versions`   | `version`, `first_seen`, `active_now`, `new_devices`, `devices_first_on_it`, `of_them_paid`, `purchases`, `uninstalls`                                      |
| `uninstalls` | `day`, `tool`, `version`, `paying`, `plan`, `days_used`, `reason`, `comment`                                                                                |

Money is in minor units of `currency`. Days are UTC dates.

## Objects

### CustomerListItem

| Attribute        | Type             |                                                                                        |
| ---------------- | ---------------- | -------------------------------------------------------------------------------------- |
| `email`          | string           |                                                                                        |
| `account`        | boolean          | An account exists for the address: it signed in, or you granted it access or added it. |
| `entitled`       | boolean          | Holds the tool now.                                                                    |
| `via`            | enum, nullable   | `licence`, `subscription`, `trial`, `grant`, or `null`.                                |
| `since`          | string           | When they first held the tool, or first signed in to it.                               |
| `last_active_at` | string, nullable | The last check-in from any of their devices.                                           |
| `devices`        | integer          | Devices signed in or activated.                                                        |

### Customer

| Attribute      | Type             |                                                                                                                                            |
| -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `email`        | string           |                                                                                                                                            |
| `account`      | boolean          | An account exists for the address: it signed in, or you granted it access or added it.                                                     |
| `entitled`     | boolean          | Holds the tool now: by a licence, subscription, trial, grant, or a bundle that names it.                                                   |
| `via`          | enum, nullable   | `licence`, `subscription`, `trial`, `grant`. `null` when not entitled.                                                                     |
| `paid_at`      | string, nullable | The licence's purchase date, or the current period's start.                                                                                |
| `plan`         | object, nullable | `id`, `name`, `billing` (`one_time` or `subscription`), `amountCents` (nullable), `currency`, `interval` (`month`, `year` or `null`).      |
| `subscription` | object, nullable | `status` (Stripe's), `periodEnd`, `cancelAtPeriodEnd`, `cancelAt`. Present whatever the status, so a lapsed subscription can be explained. |
| `trial`        | object, nullable | `endsAt`, `active`. Present after the trial ended, with `active: false`.                                                                   |

### Grant

| Attribute    | Type             |                                                                             |
| ------------ | ---------------- | --------------------------------------------------------------------------- |
| `id`         | string           |                                                                             |
| `tool`       | string           | The tool's full id.                                                         |
| `email`      | string           |                                                                             |
| `kind`       | enum             | `grant` or `trial`.                                                         |
| `starts_at`  | string           |                                                                             |
| `ends_at`    | string, nullable | When the grant ends by itself. `null` for a grant that lasts until revoked. |
| `revoked_at` | string, nullable |                                                                             |
| `note`       | string, nullable |                                                                             |
| `created_at` | string           |                                                                             |
| `live`       | boolean          | Entitles the address now.                                                   |

### Usage

| Attribute                    | Type              |                                                                                                                              |
| ---------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `object`                     | string            | `usage`.                                                                                                                     |
| `allowed`                    | boolean           | Whether the action may go ahead. On `POST`, its uses are counted when it may.                                                |
| `entitled`                   | boolean           | The buyer holds the tool: bought, subscribed, on a trial or granted. Nothing is counted.                                     |
| `unlimited`                  | boolean, optional | Nothing limits the buyer: they hold the tool, or it is open to everyone.                                                     |
| `used`, `remaining`, `limit` | integer, optional | The buyer's free uses, in the period the tool counts them in.                                                                |
| `per`                        | enum, optional    | `ever`, `day`, `week` or `month`.                                                                                            |
| `window`, `resetsAt`         | string, optional  | For a period: when the current one began, and when the next one begins.                                                      |
| `reason`                     | enum, optional    | Why not: `limit`, the free uses are spent, or `paid`, the tool is sold only.                                                 |
| `sharedUses`                 | boolean, optional | The buyer's address is a throwaway or unverified one: its uses are counted with every other such address's.                  |
| `deviceLimit`                | object, optional  | For an extension's token: its device is past the account's device limit, `allowed` and `held`, and is counted as a free one. |

Dates are ISO 8601 strings.
