# Instant integrator API guide

The integrator API is what an integrator's servers call to run their customers in Instant: the customers, their members and the members' tokens, the webhook Instant posts to, and the return URL and its origins. Credentials are issued by the Instant team, who also bring an integrator live.

The reference ([booksbyinstant.com/docs](https://booksbyinstant.com/docs)) has every path, field and status. This guide says what a reference cannot: the conventions every route follows, how to retry, how to verify a webhook, and how each object lives.

Every id and secret below is a placeholder.

## The API

- **Base.** `https://app.booksbyinstant.com`, with every route under `/api/integrator/v1`.
- **Production only.** Integrators integrate against production; there is no test environment. A customer you make while building is a real one. A customer cannot be deleted. A bank connected in it from a page on your own machine is a real bank too.
- **Versions.** Versions are in the path: a breaking change gets `v2` beside `v1`. A field may be added to an answer at any time; a client ignores fields it does not know.
- **The customer API.** A member token works on the customer API, as that member. The customer API has its own reference and guide, for integrators with a credential: they are not on this page, and [Fetching these docs](#fetching-these-docs) says how to fetch them.

## Authentication

Every request carries one secret as `Authorization: Bearer <secret>`, exactly: the scheme `Bearer`, one space, and the secret in its minted format. Anything else is `401` before anything else is read. Whether a well-formed secret is live is checked after the request's own validation: a malformed request can answer `400`, `404`, `413` or `415` before a dead secret's `401` (`PUT /api/integrator/v1/webhook` checks the credential before it looks at the URL).

| Secret | Format | Where it works | Who mints it |
| --- | --- | --- | --- |
| Integrator credential | `live_<100 hex>` | the integrator API only | the people running Instant, shown once |
| Member token | `live_m_<100 hex>` | the customer API, as that member | the integrator (`POST /api/integrator/v1/customers/{id}/members/{memberId}/tokens`), shown once |
| Webhook signing secret | `live_w_<64 hex>` | never sent to Instant; it verifies what Instant posts | Instant, on every `PUT /api/integrator/v1/webhook`, shown once |

A credential is never accepted where a member token is expected and the other way round; a session cookie or any other token is never accepted. Unknown, revoked, rotated and a disabled integrator's credentials are the same `401` "Unauthorized.", indistinguishable on purpose. A `401` carries `WWW-Authenticate: Bearer realm="instant-integrator-api"`.

Keep every secret on the integrator's servers only, in a secret manager. Instant keeps the credential and member tokens only as a SHA-512 hash, and the webhook secret encrypted, because it signs with it. Each has a hint: the prefix, `…` and the last four hex characters (`live_…`, `live_m_…`, `live_w_…`). Instant never shows a secret again.

## Requests

- A body is `Content-Type: application/json` (anything else is `415` "Use a JSON request."), one JSON object (else `400` "Check your details and try again."), within the route's size limit ([Limits](#limits); over it, declared or sent, is `413` "Request is too large.").
- Bodies are strict: a field the route does not take is `400` "Invalid <what> request: unknown field.", and the field is not repeated in the answer. A refused field is named by its path with what it must be ("Invalid customer request: member.language must be one of Instant's languages: …").
- A route that takes a query (the two lists) is strict about it: another parameter, one given twice, an empty value or a number with a leading zero is `400`. A route that takes none ignores one.
- Ids are UUIDs, matched in any case and answered in lower case. A path id that is not a UUID is the same `404` as an unknown one, never `400`. Another integrator's object is `404`, never `403`.
- References (`reference`, `member.reference`) are the integrator's own ids, 1 to 200 characters; a member reference is unique across all the integrator's customers, so a person in two customers is two members. Spaces at either end are trimmed; any other whitespace at an end (a tab, a newline, a no-break space) is refused, never trimmed.

## Responses

Every answer a route gives, success or not, is `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, except the docs themselves: a `200` or `304` of [the docs endpoints](#fetching-these-docs) is `private, no-cache`, with an `ETag`. JSON is UTF-8; times are ISO 8601 in UTC with milliseconds (`2026-11-06T10:00:00.000Z`). A create answers `201`; everything else that succeeds answers `200`.

## Errors

Every answer that is not a success is one envelope:

```json
{ "error": "A customer with that reference already exists.", "code": "CONFLICT", "existingId": "00000000-0000-7000-8000-000000000000" }
```

`error` is Instant's sentence for people; `code` is stable and is what an integrator branches on; `retryAfter` (seconds) is there only when above 0, with a `Retry-After` header (as a `429` does when there is a wait). A `409` that names an object already there carries `existingId` too.

| Status | `code` | Meaning |
| --- | --- | --- |
| `400` | `VALIDATION_ERROR` | the request is refused as sent; the sentence says why |
| `401` | `UNAUTHENTICATED` | no live secret ([Authentication](#authentication)) |
| `403` | `FORBIDDEN` | the member may not do this (a role, an approval rule) |
| `404` | `NOT_FOUND` | no such object for this caller |
| `409` | `CONFLICT` | the state does not allow it now (a duplicate, a decided approval, a changed object) |
| `413` | `LIMIT_EXCEEDED` | the body or a part is too large |
| `415` | `UNSUPPORTED_MEDIA_TYPE` | the wrong content type, or a file of a kind Instant never reads |
| `422` | `UNREADABLE_FILE` | a file of a kind Instant reads that it could not read |
| `429` | `RATE_LIMITED` | a rate limit; wait `retryAfter` when given |
| any other (`5xx`) | `UNAVAILABLE` | always "Instant is temporarily unavailable. Please try again shortly."; retry later |

A `4xx` keeps the refusal's own sentence; a `5xx` always answers the generic one. More codes may be added: treat one you do not know by its status. This API answers `400`, `401`, `404`, `409`, `413`, `415` and `5xx`; `403`, `422` and `429` are the customer API's today.

## Unknown paths and methods

What a request outside the routes gets:

| Request | Answer |
| --- | --- |
| A path under `/api/integrator/v1/` that is not a route, or one under a version that does not exist (`v2`) | `404` with the framework's HTML not-found page, **not** the envelope; `Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate` |
| A method the route does not take (`POST /api/integrator/v1/me`, `DELETE /api/integrator/v1/customers`, `PUT /api/integrator/v1/customers/{id}`) | `405` with an empty body, no `Allow` header and no envelope |
| `OPTIONS` on a route | `204` with `Allow` listing its methods and no CORS header: the API is server to server |
| `HEAD` on a `GET` route | the `GET` answer without its body |
| A path with a trailing slash (`/api/integrator/v1/`) | `308` to the path without it |

So a client treats a `404` or `405` without a JSON body as a wrong path or method in its own code, never as a missing object.

## Idempotency and retries

There is no `Idempotency-Key` header. What a repeat of each write does:

| Route | A repeat |
| --- | --- |
| `POST /api/integrator/v1/customers` | `409` "A customer with that reference already exists." with `existingId`, also when two identical creates race: the retry after a lost answer finds what it created |
| `POST /api/integrator/v1/customers/{id}/members` | `409` "A member with that reference already exists." without an id: list the customer's members and find the reference; if it is not there, another of your customers has it, because a member reference is unique across all your customers |
| `PATCH /api/integrator/v1/customers/{id}`, `PATCH /api/integrator/v1/customers/{id}/members/{memberId}` | the same values again are a `200` that changes nothing |
| `POST /api/integrator/v1/customers/{id}/members/{memberId}/tokens` | not idempotent: each call issues a new token. A token whose answer was lost is never shown again and counts toward the cap of 20 until it is revoked or expires: list the member's tokens and revoke what nobody holds |
| `DELETE /api/integrator/v1/customers/{id}/members/{memberId}/tokens/{tokenId}` | `409` "That token is no longer active.": it is revoked, as asked |
| `PUT /api/integrator/v1/webhook` | every call mints a new secret and the previous one stops at once. After a lost answer, `PUT` again and keep the new secret |
| `DELETE /api/integrator/v1/webhook`, `PUT /api/integrator/v1/return-url`, `DELETE /api/integrator/v1/return-url`, `PUT /api/integrator/v1/return-origins` | idempotent: the same state again is a `200` |

Retry a `429` after `retryAfter`, and a `5xx` with backoff. Never retry a `4xx` unchanged.

## Pagination

`GET /api/integrator/v1/customers` and `GET /api/integrator/v1/webhook/deliveries` answer a page at a time, newest first. A page's `nextCursor` is the next request's `cursor`, and it is null on the last page. Pages hold steady while customers are added: a customer created after the first page never shifts a later one. `limit` (1 to 200, 50 by default) sets the page's size; a deliveries page can be narrowed by `status`.

The other lists are whole: a customer's members and entities, oldest first, and a member's tokens, newest first.

## Limits

| Limit | Value |
| --- | --- |
| Request body | 4096 bytes by default; a member's 32 KiB, which 500 entity ids need |
| Fields | references 1–200 characters; names 1–120; URLs at most 2000; up to 500 entity ids in a selection |
| Tokens | at most 20 active, unexpired tokens per member (a resource, not a rate) |
| Rates, per integrator or per customer | none of their own yet |
| Webhook | a payload is at most 2048 bytes; a receiver has 10 seconds; 7 attempts; deliveries are kept 30 days |

## Webhooks

Instant posts to the integrator's webhook what it would otherwise have told a person: the kinds and the payload are in the reference. Instant adds kinds and fields over time: answer 2xx to a kind you do not know and drop it; ignore fields you do not know.

A notice is delivered only when its recipient is the integrator's member: a person who is not, such as an accountant a customer has let in, gets none. Deliveries continue while a customer is paused. A payload holds ids and the integrator's own references, never content: read what it is about through the customer API, as the member; for `docs.updated`, fetch the docs.

`docs.updated` concerns no customer. A production release changed a reference or a guide: this API's or the customer API's. It is sent to every active integrator with a webhook, at most once an hour; changes in the meantime go out together. `changed` names each part that changed, its new version (its ETag without the quotes) and the URL to fetch it with the integrator credential. Like every kind it is delivered at least once: deduplicate on `Instant-Event-Id`. A rollback that changes what Instant serves is announced like any other change, so a version can be announced again.

`link.finished` says that a bank connection link ended, once for each link. `item` is the `bank_link`: its `id`, as the link answered it, and its `outcome`, `connected`, `cancelled`, `failed` or `expired`. A link expires when nobody opened it within 15 minutes, or its consent did not come back within the hour. Instant tells of that end within about a quarter of an hour. Like every delivery, it can come late or after a later one. Read the link by its id through the customer API for its status, its connection and why it failed.

### Headers and verification

Each delivery is a `POST` of the payload with:

```
Content-Type: application/json
User-Agent: Instant-Webhooks/1
Instant-Event-Id: <the payload's id>
Instant-Event-Kind: <the payload's kind>
Instant-Signature: t=<unix seconds when this attempt was sent; each retry is signed again>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" keyed with the whole secret>
```

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// secret: the live_w_<64 hex> string from PUT /api/integrator/v1/webhook, kept in a secret manager.
// rawBody: the request body exactly as received, a Buffer, before any parsing.
export function verify(secret, header, rawBody, now = Date.now() / 1000) {
  const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '') ?? [];
  if (!t || Math.abs(now - Number(t)) > 300) return false; // a 5-minute tolerance
  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(v1, 'hex');
  return given.length === expected.length && timingSafeEqual(expected, given);
}

// In the receiver: verify the raw bytes before parsing, then deduplicate on the event id.
// if (!verify(secret, request.headers['instant-signature'], rawBody)) return respond(401);
// if (await seen(request.headers['instant-event-id'])) return respond(200);
// await enqueue(JSON.parse(rawBody)); return respond(200);
```

- Verify the raw body as received, before parsing it; check the timestamp.
- Delivery is **at least once**: the same event can arrive more than once, always with the same `Instant-Event-Id`, so deduplicate on it.
- Answer any `2xx` within 10 seconds and do the work afterwards; the body is read up to 1 KiB and ignored. A redirect is a failure and is never followed.
- Test a duplicate delivery, a slow answer and a receiver outage before going live.

### Retries and retention

A delivery has 7 attempts, due at 0 s, 1 m, 5 m, 30 m, 2 h, 6 h and 24 h after its creation; a late attempt skips the offsets it missed. Delivery runs once a minute, so an attempt can follow its due time by a minute or more. After the seventh failure it is `failed` and never sent again. While the integrator is disabled its deliveries wait unsent. Deliveries are kept 30 days: a pending one older than that fails `expired`, and delivered and failed ones are then deleted. There is no replay; the deliveries list and the customer API are how a missed event is found.

## Lifecycle

| Object | States | What the integrator sees |
| --- | --- | --- |
| Integrator | `active` → `disabled` → `active` (the people running Instant) | Disabled: the credential and every member token are `401`; no agent work runs in its customers; deliveries wait; a member's connection links are refused. Enabled again, all of it works; tokens were not revoked |
| Credential | rotated by the people running Instant | The old credential is `401` at once; there is no overlap. Agree a cut-over time |
| Customer | `active` ↔ `paused` (`PATCH /api/integrator/v1/customers/{id}`) | Paused: Instant answers every chat message with a fixed sentence that the customer is paused, and runs no scheduled, background or event work; reads, decisions, uploads, connections and member changes work; `customer.paused` / `customer.resumed` are posted. Nothing is deleted. There is no delete |
| Member | active → deactivated → active (`PATCH /api/integrator/v1/customers/{id}/members/{memberId}`) | Deactivated: every token is revoked for good (`revokedReason: "deactivated"`) and `401`; reactivated: role and access come back, tokens do not. There is no delete. The owner and the last admin cannot be deactivated |
| Member token | `active` → `expired` or `revoked` | Both are `401`. Revoked by the integrator (`integrator`), by deactivation (`deactivated`) or by the people running Instant (`operator`) |
| Webhook | set ↔ removed (`PUT /api/integrator/v1/webhook`, `DELETE /api/integrator/v1/webhook`; the people running Instant may remove it) | Removed: pending deliveries fail `webhook_removed`; the history stays 30 days |
| Return URL | set ↔ cleared (the people running Instant may clear it) | Cleared: a connection ends on Instant's own result page |
| Return origins | set, `[]` clears (`PUT /api/integrator/v1/return-origins`) | Up to ten origins your return URLs may be on, each exactly as a browser writes it: https, or http on a loopback address on your own machine. The return URL's own origin counts without being listed |
| Delivery | `pending` → `delivered` or `failed` | 7 attempts in 24 hours; kept 30 days |
| Connection link (a member's one-use link to connect a mailbox, a calendar or a bank) | unused → used or expired | 15 minutes, once |

## Fetching these docs

This guide and the reference are served with the integrator credential, so your servers can fetch them:

- `GET /api/integrator/v1/docs/guide.md` answers this guide, as `text/markdown; charset=utf-8; variant=GFM`.
- `GET /api/integrator/v1/docs/openapi.json` answers the reference, an OpenAPI 3.1 document.

Without a query they answer the integrator API's docs. With `?api=customer` they answer the customer API's guide and reference, fetched with the same integrator credential.

Each answer has an `ETag`: the version of that file, an opaque string that changes when the file changes, and only then. Keep it with your copy and send it back as `If-None-Match`; while your copy is current, the answer is `304` without a body. The answers are `Cache-Control: private, no-cache`: keep your copy, and ask again before you use it. A fetch is a use of the credential like any other request, a `304` included.

With a webhook, `docs.updated` says when to fetch: fetch once after you set the webhook, then when a notice names a part. Without one, check at most once an hour. The public docs page updates within the hour after a notice.
