npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@allus-fyi/company-data

v0.0.21

Published

TypeScript/Node SDK for the allus company-data API: typed, plaintext, slug-keyed conclusions with transparent decryption.

Readme

@allus-fyi/company-data (TypeScript / Node)

The TypeScript/Node SDK for the allus company-data API. Point it at a JSON config file and it hands back typed, plaintext, your-slug-keyed conclusions: for each connected person, a map of your request-field slug → plaintext value (plus whether the value is live and when it last changed).

The SDK hides everything else — the OAuth token, the field catalog, the id plumbing, the hybrid decryption, binary fetching, the changes-queue mechanics, JSON-vs-XML. The platform is zero-knowledge: the API only ever holds ciphertext, so all decryption happens inside the SDK with your service private key. The person's own field choices are never exposed — you only ever see the request slots you configured.

This SDK is one of six language ports that share an identical API surface. This manual is the TypeScript view of it.

Contents: TL;DR — fetch new updates · Quickstart · Every call · The typed value model · The changes pump · Webhooks · Rate limits · Errors · How it's wired


TL;DR — fetch new updates

npm install @allus-fyi/company-data

Point a config.json at your service keys:

{
  "api_url": "https://api.allme.fyi",
  "client_id": "svc_xxx",
  "client_secret": "xxx",
  "service_private_key": "/path/to/service.pem",
  "key_passphrase": "xxx",
  "cache_dir": "./allus-cache"
}

Drain everything new, handled one update at a time:

import { Client } from '@allus-fyi/company-data';

const client = Client.fromConfig('config.json');

await client.processChanges((change) => {
  // event, person, slug, value, live, at
  console.log(change.event, change.personId, change.slug, change.value, change.live, change.at);
});

processChanges pulls every pending change, decrypts it, and hands them to your callback ONE BY ONE, acking each only after your code returns. Crash mid-batch? The next run replays exactly what wasn't acked — nothing is lost, and the API keeps no backlog of its own. Run it on a schedule (cron / systemd timer); there is no daemon/follow mode by design. Connections, binary values, and webhooks are documented below.

Deeper reference pages live in docs/: config · model · pump · webhooks · errors.


Quickstart

Requires Node ≥ 18 (it uses the built-in global fetch and node:crypto). The package ships dual ESM + CommonJS with bundled .d.ts types.

npm install @allus-fyi/company-data
# or, working from this repo:  npm install && npm run build   # from the repo root
// ESM
import { Client } from '@allus-fyi/company-data';
// CommonJS
const { Client } = require('@allus-fyi/company-data');

1. Write a config file

A single JSON file holds everything. Any field can be overridden by an ALLUS_* env var, so secrets needn't live in the file. No SDK method ever takes a key, passphrase, or secret as an argument — they all come from here.

allus.json:

{
  "api_url": "https://api.allme.fyi",
  "client_id": "svc_1a2b3c…",
  "client_secret": "…",
  "service_private_key": "./service-CRM.pem",
  "key_passphrase": "…",

  "account_private_key": "./account.pem",
  "account_passphrase": "…",

  "webhooks": {
    "wh_abc123": "hmac_secret_for_that_webhook"
  },

  "cache_dir": "./allus-cache",
  "format": "json"
}

| Field | Required | Meaning | |-------|----------|---------| | api_url | yes | API base, e.g. https://api.allme.fyi. | | client_id / client_secret | yes | The registered client_credentials credentials for one service. | | service_private_key | yes | Path to the OpenSSL-encrypted PKCS#8 PEM you downloaded from the portal. | | key_passphrase | yes | Decrypts that PEM in memory at startup. | | account_private_key / account_passphrase | only for encrypt_payload webhooks | The company account key, used to unwrap an encrypted webhook envelope. | | webhooks / webhook_secret | webhook auth — HMAC (default) | Per-webhook HMAC secrets keyed by webhook id (matched via the X-Allus-Webhook-Id header). A single-webhook service can use a flat "webhook_secret": "…" instead of the map. | | webhook_bearer_token | webhook auth — bearer | Verify Authorization: Bearer <token> deliveries. | | webhook_basic | webhook auth — basic | {"username","password"} — verify HTTP Basic deliveries. | | webhook_header | webhook auth — header | {"name","value"} — verify a custom-header delivery. | | webhook_auth_none | webhook auth — none | true — explicit opt-out; verifyWebhook always passes (use only behind your own gateway). Configure at most one webhook auth method (two+ → ConfigError). | | cache_dir | no (default ./allus-cache) | Durable local buffer for the changes pump. Must be writable + durable. | | format | no (default json) | Wire format json or xml. Invisible in the output. |

The config-file keys are snake_case (api_url, client_secret, …); the SDK exposes them as camelCase (config.apiUrl, …). Env overrides use the ALLUS_ prefix, e.g. ALLUS_CLIENT_SECRET, ALLUS_KEY_PASSPHRASE, ALLUS_ACCOUNT_PASSPHRASE, ALLUS_WEBHOOK_SECRET. A missing/invalid config (or an unreadable PEM / wrong passphrase) throws ConfigError at construction — fail fast.

2. First call — list a connection's values

import { Client } from '@allus-fyi/company-data';

const client = Client.fromConfig('allus.json');

// Iterate every connected person (lazy, auto-paged).
for await (const conn of client.connections()) {
  console.log(conn.displayName, conn.personId);
  for (const [slug, val] of Object.entries(conn.values)) {
    console.log(`  ${slug} = ${JSON.stringify(val.value)}  (live=${val.live}, updated=${val.updatedAt})`);
  }
  break; // just the first one for the demo
}

Or fetch one connection by id:

const conn = await client.connection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
const email = conn.values['work_email'].value;        // "[email protected]"  (a string)

Client.fromEnv() builds the same client entirely from ALLUS_* env vars (no file).


Every call

Client is the only object you construct. Build it from config, then:

Client.fromConfig(path, opts?): Client     // from a JSON file (env overrides secrets)
Client.fromEnv(opts?):          Client      // entirely from ALLUS_* env vars

opts are advanced/optional: http (an injected HttpClient), httpOptions (passed to the default HttpClient: transport, clock, maxRetries429), logger (a console-compatible sink for the pump), sleep (a (seconds) => Promise<void>, for tests).

requestFields()

requestFields(): Promise<RequestField[]>

Your request-field definitions — fetched once from GET /api/company-data/request-fields and cached for the life of the client (it types every value). Returns your request config, never the person's fields.

  • Params: none.
  • Returns: Promise<RequestField[]> — each RequestField { slug, label, type, oneTime, mandatory, verified, verifiedMaxAgeDays, raw }. mandatory is true when the field is mandatory-to-provide or mandatory-to-stay-connected.
  • Throws: AuthError, ApiError, RateLimitError.
for (const f of await client.requestFields()) {
  const flag = f.mandatory ? 'mandatory' : 'optional';
  console.log(`${f.slug}  ${f.type}  ${flag}${f.oneTime ? ' (one-time)' : ''}`);
}

connections(limit?, offset?)

connections(limit?: number, offset?: number): AsyncGenerator<Connection>

A lazy async generator that auto-pages GET /api/company-data/connections?limit&offset and yields one typed Connection at a time (bounded memory for a large book). Each conn.values[slug] is already decrypted (or a lazy binary handle). It honors the response's total so it never over-fetches a page past the end (and also stops on a short page).

  • Params: limit — page size (default 100); offset — starting offset.
  • Returns: AsyncGenerator<Connection> — consume with for await.
  • Throws: AuthError, ApiError, DecryptError (per value, on access), RateLimitError (after the iterator's bounded internal backoff — see Rate limits).

Heavily rate-limited. Use for the initial full sync + occasional reconciliation only — never as a poll substitute for the changes feed. The generator paces itself within the limit (backs off on Retry-After).

// Initial full sync, streaming so a 100k-connection book never lands in memory.
for await (const conn of client.connections(200)) {
  await upsertLocalRecord(conn);
}

connection(id)

connection(id: string): Promise<Connection>

Fetch one connection by its connection id (GET /api/company-data/connections/{id}).

  • Params: id — the connection id (Connection.id).
  • Returns: Promise<Connection>. Note: this endpoint returns {connection_id, user_id, values} and no displayName/connectedAt, so those identity fields are null here (the list endpoint carries them).
  • Throws: AuthError, ApiError (404 if unknown), DecryptError, RateLimitError.
const conn = await client.connection(connId);
const phone = conn.values['mobile'];
if (phone) console.log(phone.value, phone.live ? 'live' : 'snapshot');

logs(limit?, offset?)

logs(limit?: number, offset?: number): Promise<LogEntry[]>

The service's activity log (GET /api/company-data/logs?limit&offset) — ops events only (email / purge / webhook), never person field data.

  • Params: limit (default 50), offset (default 0).
  • Returns: Promise<LogEntry[]> — each LogEntry { type, message, metadata, at, raw }.
  • Throws: AuthError, ApiError, RateLimitError.
for (const entry of await client.logs(20)) {
  console.log(entry.at, entry.type, entry.message);
}

processChanges(handler, options?)

processChanges(handler: (change: Change) => void | Promise<void>, options?): Promise<void>

The crash-safe changes pump: drains the feed through handler one Change at a time, durably buffering each batch before delivery, with per-item ack and retry → dead-letter → continue. Runs until the feed is empty, then resolves — there is no follow/daemon mode (you schedule re-runs yourself). Delivery is at-least-once, so your handler must be idempotent (dedup on Change.id). See The changes pump for the full model.

  • Params: handler — your callback; called with one Change. Resolving/returning is an ack; throwing triggers retry. May be sync or async.
  • Options: batchSize (clamped to ≤ 500, default 100), maxRetries (default 3), onError ("deadletter" — default — or "halt"), backoff ((attempt) => seconds).
  • Returns: Promise<void> (resolves when the feed is empty + the buffer is drained).
  • Throws: AuthError, ApiError, RateLimitError (during a drain); TypeError (bad onError); whatever the handler throws if onError="halt" and retries are exhausted.
async function handle(change) {
  if (await alreadyProcessed(change.id)) return;   // idempotency — dedup on the stable id
  if (change.event === 'field_updated') {
    await store(change.personId, change.slug, change.value);
  } else if (change.event === 'connection_deleted' || change.event === 'field_deleted') {
    await remove(change.personId, change.slug);
  }
  await markProcessed(change.id);
}

await client.processChanges(handle);            // resolves when the feed is empty

logger is not a processChanges option — pass it once to the Client constructor (Client.fromConfig('allus.json', { logger: myLogger })).

Advanced changes primitives

drainBatch(max?: number)                            : Promise<Change[]>     // raw, UNBUFFERED — you own durability
deadLetters()                                       : DeadLetterRecord[]    // the local dead-letter store
retryDeadLetters(handler, options?)                 : Promise<number>       // re-drive dead-lettered events; resolves to count re-driven
  • drainBatch(max) — fetches one batch (clamped ≤ 500) and returns the decrypted Changes directly. It does not persist anything, so a crash loses what the API already deleted. Prefer processChanges for safe consumption.
  • deadLetters() — each record is the stored (ciphertext) event plus a flattened error and attempts.
  • retryDeadLetters(handler, options?) — same maxRetries / onError / backoff options as processChanges; on success a record is removed, on repeated failure it stays dead-lettered (or re-throws under "halt"). Dead letters are never re-fetched from the API — the local store is their only home.
for (const dl of client.deadLetters()) {
  console.log('stuck:', dl.id, dl.error, 'after', dl.attempts, 'attempts');
}
const n = await client.retryDeadLetters(handle);     // after you've fixed the bug
console.log(`re-drove ${n} dead letters`);

Key rotation — key_rotated and the public-key cache

Every client caches the RSA public keys it fetches: a person's key is immutable — until they rotate it. A person learns of a rotation from a silent push; your service gets no pushes, so the key_rotated change is your only signal. Without it a long-running worker keeps encrypting to the rotated-away key for its whole lifetime, and the person can never read those values.

On the pump this is automatic — the cached key is dropped as the change passes through, before your handler sees it. Over a webhook it is not: the signature verifier is static and has no client instance, so it cannot reach the cache. Call the invalidator yourself — noting that the two clients key their caches differently: the service client by share_code, the customer client by the person's user id. Passing a share code to the customer client removes nothing and leaves you encrypting to the old key. Both identifiers ride every change, alongside public_key_sha256 — the fingerprint of the person's new key.

if (change.event === 'key_rotated') {
  client.invalidatePublicKey(change.shareCode);    // service Client — keyed by SHARE CODE
  customer.invalidatePublicKey(change.personId);   // CustomerClient — keyed by PERSON USER ID
  // change.publicKeySha256 = fingerprint of the NEW key, if you want to verify the refetch
}

This is eventual, not fail-closed — nothing rejects a document encrypted to a stale key, so a window remains between the rotation and your next drain. Drain often if that window matters.

service_key_rotated — the same thing, the other way round

The CustomerClient also caches the service's public key, the one you encrypt your consent answers and documents to, keyed "companyCode/serviceCode". When that company replaces its service keypair, the service_key_rotated change on your account feed is your only signal — you receive no pushes. Same shape, same guarantees, same automatic handling on the pump:

if (change.event === 'service_key_rotated') {
  // Automatic on the pump. Over a webhook, from the raw event body:
  customer.invalidateServiceKey(body.company_share_code, body.service_share_code);
  // body.service_public_key_sha256 = fingerprint of the service's NEW key
}

Also eventual, not fail-closed. Note the identifiers are share codes, not the ids used by invalidatePublicKey — the two caches are keyed differently and the wrong call removes nothing.

Webhook helpers (on the client)

The webhook receiver helpers are also exposed as Client methods (they delegate to the module functions, fully config-driven — no key/secret arguments):

client.verifyWebhook(rawBody: Buffer | Uint8Array | string, headers): boolean
client.parseWebhook(rawBody, headers):  Change
client.handleWebhook(rawBody, headers): Change   // verify + parse
  • verifyWebhook — recomputes HMAC-SHA256(rawBody, secret) and constant-time-compares it to X-Allus-Signature. Returns true/false; never throws for a bad signature.
  • parseWebhook — body → a typed Change. Does not verify. Handles JSON, XML, and the encrypt_payload account-key envelope. Throws WebhookError on a malformed/unparseable body.
  • handleWebhook — verify then parse; throws WebhookError on a bad/unknown signature, otherwise returns the Change. The typical one-liner inside a route.

The client webhook methods are synchronous and require the request-fields catalog (for value typing). Call await client.requestFields() once at startup so the catalog is cached before you handle webhooks (the catalog fetch is the only network call these methods would need, and it must be done up front since they are sync).

The same three are importable as standalone functions (import { verifyWebhook, parseWebhook, handleWebhook } from '@allus-fyi/company-data'), which take the config and the decrypt/type closures explicitly — but inside an app you'll almost always use the client methods. See Webhooks.


The typed value model

You work with these objects and nothing else (import { … } from '@allus-fyi/company-data'):

RequestField { slug, label, type, oneTime, mandatory, verified, verifiedMaxAgeDays }
Connection   { id, personId, displayName, connectedAt, values: {<slug>: Value} }
Value        { value, live, updatedAt, verified, verifiedAt, verifiedExpiresAt }
Change       { id, event, personId, slug?, value?, live?, at }
LogEntry     { type, message, metadata, at }

Keyed by your slug

conn.values['work_email'].value"[email protected]". The key is the stable, explicit slug you set per request field in the portal — rename the label freely, the slug is the contract. The person's source field is never exposed: no source slug, no field_id, not even via .raw.

Value { value, live, updatedAt, verified, verifiedAt, verifiedExpiresAt }

| Property | Meaning | |----------|---------| | value | The typed plaintext (see the table below). | | live | true if the person chose "keep connected" (auto-updates); false for a one-time snapshot. | | updatedAt | Date of when this answer last changed (per-answer, rides on the Value), or null. | | verified | true only when the verification hash recomputes over the decrypted plaintext and the verification has not lapsed. Absent metadata reads false, which means "not attested", not "wrong". | | verifiedAt | Date the answering field was verified, or null. A stamp, not a promise about today. | | verifiedExpiresAt | Date that verification lapses, or null when it does not. A document-backed verification dies with the document; once this is past, verified reads false. |

Value types (from the field's type)

| Field type | JS value | |------------|------------| | email, phone, url, text | stringphone is a single E.164-style string (+ and digits) | | country, nationality | string — an ISO 3166-1 alpha-2 code (e.g. 'US', 'NL'); not a display name | | address, bank, creditcard | a parsed object — the decrypted plaintext is a JSON object, parsed for you | | date, date_of_birth | a Date (UTC midnight; falls back to the raw string if it can't be parsed) | | photo, document, legal_document, passport, photo_id, drivers_license | a lazy BinaryHandle — see below. The last three are ID-document subtypes of legal_document. | | unanswered / no value | null |

const addr = conn.values['home_address'].value as Record<string, unknown>; // {street, city, …}
const dob  = conn.values['birthday'].value as Date;                          // Date(1990-05-17)

country/nationality values are 2-letter ISO codes, and an address's country/state sub-fields are an ISO alpha-2 code / USPS 2-letter state code respectively. isFieldValueValid(type, value) validates these against the bundled country dataset; isValidCountryCode(code) / dialCodeFor(code) check a code or look up its E.164 dial code.

Binary fields — the lazy BinaryHandle

A photo/document value is a BinaryHandle. Nothing is fetched or decrypted until you call .bytes() or .save():

const handle = conn.values['passport_scan'].value as BinaryHandle;  // no network yet

const data = await handle.bytes();                  // GET the slot file → the file bytes
const n    = await handle.save('/tmp/passport.jpg'); // same, written to disk; returns bytes written
console.log(handle.valueUrl);                         // the opaque slot-keyed URL it fetches from
console.log(handle.contentType, handle.contentSha256); // what arrived, and its digest

The endpoint has two 200 shapes, and which one you get is the person's choice, not yours — it depends on whether their source field is private, they can change it at any time, and nothing announces it in advance:

  • private sourceapplication/json {"encrypted": true, "value": <wrapper>}. The wrapper is decrypted with your service key into a JSON file-envelope ({"full": "data:…"} for photos, {"file": "data:…"} for documents) whose data URI base64-decodes to the file.
  • plaintext source → the file's own Content-Type (image/jpeg, application/pdf, …) and the body already IS the file. Nothing is decrypted, and no service key is needed.

.bytes()/.save() hide the difference and give you the file bytes either way; the handle tells the two apart on the response Content-Type and never by sniffing the body. There is no variant selection — one slot has one byte sequence and therefore one digest. Every 200 carries X-Allus-Content-Sha256, the sha256 of exactly the bytes returned, exposed as handle.contentSha256 (and handle.contentType) after the first fetch, so you can record what you received and later show your archived copy has not drifted. The result is cached on the handle, so repeated calls don't re-fetch. .save() is crash-safe (temp file → fsync → atomic rename).

A frozen (share-once) answer is retained for 90 days. After that the endpoint answers 410 with error_key: company_data.file_expired, surfacing as an ApiError whose details carry the content_sha256 the file had and its expired_at — so your copy is now the only one, and you can still prove what it is. The values map may also carry content_sha256 / expired / expired_at on a binary slot ahead of the fetch; those ride verbatim on Value.raw (and on Change.raw for a field_deleted event).

Change { id, event, personId, slug?, value?, live?, at }

A change-feed / webhook event.

| Property | Meaning | |----------|---------| | id | The stable server change-row id — your dedup key (captured before the server delete). | | event | connection_created, connection_deleted, field_updated, field_deleted, consent_accepted, consent_declined, document_status_changed, message_received. | | personId | The person the change is about (may be null). | | slug, value, live | Present only on field_updated; value is typed exactly like Value.value (incl. a lazy BinaryHandle for binaries). Connection/consent/document events carry no slot/value. | | documentId, status | Present only on document_status_changed — the affected document's id and its new lifecycle status. null on every other event. | | connectionId, messageId, personPublicKey, messageBody | Present only on message_received — a person messaged your service. messageBody is the decrypted text. See Messaging. | | verified, verifiedAt, verifiedExpiresAt | Present on field_updated, with the same meaning as on Value. | | at | Date of the change. (There is no separate updatedAt on a change.) |

.raw

Every model carries .raw — the underlying hardened API object — for debugging or an edge case the SDK didn't model. It still never contains the person's source field.

See docs/model.md for the full reference.


The changes pump

The changes feed is a server-side drain-on-fetch queue: GET /api/company-data/changes?limit=N returns up to N events (default 100, max 500) and deletes exactly those rows in the same transaction — no offset/cursor, and the API keeps no copy afterward. So consumption can't be a plain list: a consumer crash mid-batch would lose events the API already deleted, and a huge backlog must not materialize in memory. processChanges solves both.

Per run, repeating until the feed is empty then resolving:

  1. Replay first. Deliver any un-acked events already in the local buffer (from a previous crashed run), oldest-first.
  2. Drain. When the buffer is empty, fetch one batch and persist it to the durable file buffer (fsync) BEFORE handing anything out. This is the backup the API no longer has.
  3. Deliver one-by-one. For each buffered event, oldest-first: decrypt its value at delivery (never on disk), build the typed Change, call handler.
  4. Ack / retry / dead-letter. On success, remove the event from the buffer (ack). On a handler error, retry with backoff up to maxRetries; then either move it to the dead-letter store and continue (onError="deadletter", default — one poison event never wedges the stream) or stop and re-throw (onError="halt"). A DecryptError on a buffered event (corrupt/truncated ciphertext, rotated key) is dead-lettered immediately — re-decrypting can't fix it, so it does not burn retries (under onError="halt" it re-throws). Either way it never propagates out and wedges replay.
  5. Repeat until a drain returns empty and the buffer is drained → resolve.

The durable buffer

  • Plain files under cacheDir (zero extra dependencies): pending/ for un-acked events, deadletter/ for ones that exhausted retries.
  • Stored events keep their ciphertext value — no plaintext PII is ever written to disk. Decryption happens only at delivery.
  • Writes are crash-safe (temp file → fsyncSync → atomic rename → dir fsync). Files are named with a monotonic, zero-padded sequence so they replay oldest-first.

Crash safety, at-least-once, and idempotency

A batch is durably buffered before any delivery, and acked per-item only after the handler succeeds. The ack can't be atomic with your side-effects — a crash between your handler's success and its ack re-delivers that event on the next run. That makes delivery at-least-once, so:

Your handler must be idempotent. Dedup on Change.id.

Change.id is the stable server change-row id, captured before the server delete, so it survives crash + replay unchanged.

No follow mode

processChanges resolves when the feed empties. You schedule re-runs — a cron job, a while (true) { await client.processChanges(handle); await sleep(5000); } loop, a worker queue, whatever fits. The feed is cheap to poll (see Rate limits).

Worked example

import { Client } from '@allus-fyi/company-data';

const client = Client.fromConfig('allus.json');
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function handle(change) {
  if (await seen(change.id)) return;            // idempotent: skip what we've applied
  switch (change.event) {
    case 'field_updated':
      await storeValue(change.personId, change.slug, change.value, change.live);
      break;
    case 'field_deleted':
      await clearValue(change.personId, change.slug);
      break;
    case 'connection_deleted':
      await dropPerson(change.personId);
      break;
    case 'connection_created':
    case 'consent_accepted':
    case 'consent_declined':
      await noteEvent(change.personId, change.event, change.at);
      break;
  }
  await recordSeen(change.id);
}

// Schedule your own re-runs; processChanges itself resolves when empty.
for (;;) {
  await client.processChanges(handle, { batchSize: 200, maxRetries: 5 });
  await sleep(5000);
}

If a handler keeps failing, the event lands in the dead-letter store instead of blocking the stream; inspect with client.deadLetters() and re-drive with client.retryDeadLetters(handle) after fixing the cause. See docs/pump.md.


Webhooks

Webhooks are the lower-latency push alternative to polling the changes feed. The platform POSTs each change event to your configured webhook URL with:

  • X-Allus-Webhook-Id — which webhook this is (selects the HMAC secret from config).
  • X-Allus-SignatureHMAC-SHA256(rawBody, secret) as lowercase hex.
  • the body — the same slug-keyed Change shape as the pull feed (JSON or XML).

All secrets/keys come from config; the helpers take no key or secret arguments. Use the raw request body bytes (Buffer) — do not re-serialize a parsed body, the HMAC is over the exact bytes the platform sent.

Delivery contract — effectively unique, rarely replayed

Each queued event is POSTed once, and only HTTP 200 counts as delivered — a 202, a 204, a 3xx redirect and every 4xx/5xx are all treated as a failure. On anything other than 200 (or a timeout or connection error) the event is not retried in place: it and the rest of the webhook's queue move to a durable server-side backlog and the webhook is marked bad. The backlog is delivered later, either automatically when the webhook next probes healthy, or when you drain it yourself with GET /api/company-data/changes?webhook_id=… (delete-on-read).

So deliveries are effectively unique — with one rare exception. If your endpoint processed an event but the platform never saw your 200 (your response timed out, or you crashed after committing but before responding), the event is treated as failed and replayed on recovery, so you receive it again. Nothing caps that at two: a failed probe leaves its backlog row in place, so every later recovery attempt whose 200 is likewise lost replays the same event once more. Inside that window the contract is at-least-once — plan for one or more repeats, not for exactly one.

Do not use change.id as an idempotency key here. On the webhook path the id is neither reliably stable nor reliably fresh, and a receiver cannot tell which one it is holding. A live delivery is built with no change row behind it, so its id is minted for that single POST — the later replay of the same event is rebuilt from a durable backlog row and therefore carries a different id. But a replayed delivery carries that row's id, and the row stays in place until it is delivered successfully, so a re-attempted replay arrives with the same id — which changes again if the event is re-backlogged after a further failure. An id check therefore misses the duplicate you are most likely to see and matches only a rarer one; it is not a contract. If you need strict idempotency, key on the content — event + person + slug/document + payload — never on the id.

Webhooks and the pull feed are alternative integrations — consume one, never both. The id-dedup guidance in the changes-pump section above applies to the pump only, where change.id is the real server change-row id.

In a web route (Express)

import express from 'express';
import { Client, WebhookError } from '@allus-fyi/company-data';

const app = express();
const client = Client.fromConfig('allus.json');
await client.requestFields();   // warm the catalog once (the webhook methods are sync)

// IMPORTANT: capture the RAW body bytes — do not let a JSON body-parser replace them.
app.post('/allus/webhook', express.raw({ type: '*/*' }), (req, res) => {
  let change;
  try {
    change = client.handleWebhook(req.body /* Buffer */, req.headers);
  } catch (e) {
    if (e instanceof WebhookError) return res.sendStatus(401); // bad/unknown signature
    throw e;
  }
  // Do NOT carry the pump's id-dedup over here: the webhook id is not an idempotency
  // key (see "Delivery contract" above). Key on content if you need one.
  applyChange(change);
  res.sendStatus(200);   // 200 — the ONLY status allus counts as delivered
});

verifyWebhook / parseWebhook let you split the steps if you prefer:

if (!client.verifyWebhook(rawBody, headers)) return res.sendStatus(401);
const change = client.parseWebhook(rawBody, headers);

Config-driven secrets

Per-webhook HMAC secrets live in the config webhooks map, keyed by webhook id; the SDK reads X-Allus-Webhook-Id off the request and looks up the matching secret. A single-webhook service can use the flat "webhook_secret": "…" shortcut (or ALLUS_WEBHOOK_SECRET). An unknown/unconfigured id ⇒ verification returns false (and handleWebhook throws WebhookError).

The encrypt_payload account-key envelope

If a webhook has encrypt_payload enabled, the body is replaced by a {"_enc":1,…} envelope encrypted to your company account key (and the HMAC is over that envelope — the final bytes sent). parseWebhook/handleWebhook unwrap it transparently using the configured account_private_key + account_passphrase, then decrypt the inner field value with the service key — so an encrypted-payload Change is identical to a plain one. If you receive such a webhook without an account_private_key configured, you get a WebhookError.

The account-key envelope uses OAEP-SHA1 (OpenSSL's default), distinct from the OAEP-SHA256 used for person field values — the SDK handles this difference internally; you only supply the account key in config.

See docs/webhooks.md.


Company documents

A service can attach documents to a connection — contracts, statements, receipts, anything — either broadcast to every connection or aimed at one person. A document carries a small JSON payload (payloadKind:'json') or a file blob (payloadKind:'file'), a kind/name/description, a lifecycle status, and free-form metadata.

The one rule: the target decides encryption, not isPrivate

  • Per-person (you pass connectionId, personUserId, or shareCode) → the value is always end-to-end encrypted to the recipient's public key before it leaves the process — for every per-person document, isPrivate or not. The server only ever stores ciphertext. No method takes a key or secret argument; the SDK resolves the recipient's share code (from connectionId/personUserId) and fetches the key for you (pass shareCode to skip that lookup).
  • Broadcast (no target) → the value is sent plaintext (you can't single-key encrypt to all of a service's connections), so a broadcast must be non-private.

isPrivate is therefore device-display-only — it tells the recipient's app to lock the document (tap-to-reveal) vs decrypt-on-load; it does not change whether the value is encrypted. Because a plaintext broadcast can't be locked, isPrivate: true with no target throws ConfigError.

Create

// BROADCAST — plaintext json, visible to every connection (no target; non-private)
const notice = await client.createDocument({
  kind: 'notice',
  name: 'August price update',
  payloadKind: 'json',
  jsonValue: { effective: '2026-08-01', changePct: 4 },
});

// PER-PERSON — automatically encrypted to the recipient (target by connectionId,
// personUserId, or shareCode — any one resolves the key)
const contract = await client.createDocument({
  kind: 'contract',
  name: 'Service agreement',
  payloadKind: 'json',
  connectionId: 'conn-uuid',          // or personUserId / shareCode
  isPrivate: true,                    // device locks it; value is encrypted regardless
  jsonValue: { plan: 'pro', signedBy: null },
  status: 'ready_to_sign',
  metadata: { ref: 'CT-2026-0042' },
});

// PER-PERSON FILE — bytes are encrypted to the recipient too
const receipt = await client.createDocument({
  kind: 'receipt',
  name: 'Invoice 0042.pdf',
  payloadKind: 'file',
  personUserId: 'person-uuid',
  fileBytes: pdfBuffer,              // Buffer | Uint8Array
  fileMime: 'application/pdf',
});

payloadKind selects the body: 'json' requires jsonValue (any JSON-serialisable object); 'file' requires fileBytes (and an optional fileMime). For per-person json docs, read the plaintext back with .json() — it decrypts transparently with the SDK's own key; broadcast json is already plaintext.

List, fetch, update, delete

listDocuments(opts?: { personUserId?; status?; limit?; offset? }): Promise<Document[]>
document(documentId): Promise<Document>
documentFile(documentId): Promise<Buffer>                       // #491: the file BYTES
updateDocumentStatus(documentId, status): Promise<Document>     // offering|ready_to_sign|active|active_but_ending|ended
updateDocumentMetadata(documentId, { metadata?, name?, description? }): Promise<Document>
deleteDocument(documentId): Promise<void>                       // also removes the on-disk file
const docs = await client.listDocuments({ personUserId: 'person-uuid', status: 'active' });
const doc = await client.document(contract.id);
const payload = doc.json();                          // decrypted plaintext (per-person) or as-is (broadcast)

await client.updateDocumentStatus(contract.id, 'active');
await client.updateDocumentMetadata(contract.id, { metadata: { ref: 'CT-2026-0042', signed: true } });
await client.deleteDocument(notice.id);

A Document is { id, kind, name, description, status, payloadKind, isPrivate, value, metadata, createdAt, updatedAt, raw } with a .json() helper for json docs.

  • listDocuments(opts) filters optionally by personUserId and/or status and pages with limit/offset.
  • document(id) fetches one. Call .json() on a 'json' document to get the plaintext (it transparently decrypts a per-person, encrypted document; a broadcast doc is already plaintext).
  • documentFile(id) (#491) downloads a 'file' document's BYTES — the metadata methods don't include them. A broadcast (plaintext) document's bytes are returned as-is; a per-person / private document is encrypted to the recipient's key (not your service key), so documentFile fails clearly with documents.recipient_encrypted (ApiError) rather than a doomed decrypt. For a generated flow contract's own copy use flowRunDocument(runId) below (that copy IS service-key-encrypted).

Contract flows & identity (#491)

flowRunAnswers(run: FlowRun | string): Promise<Record<string, unknown>>  // gap 1 — a completed run's DECRYPTED answers {slug: plaintext}
flowRunDocument(runId): Promise<Buffer>                                  // gap 2 — the company's own copy of a run's generated contract (plaintext bytes)
identity(): Promise<{ company_user_id: string; service_id: string }>     // gap 3 — this client's own identity
  • flowRunAnswers(run) returns a completed run's decrypted {slug: plaintext} answers (accepts a fetched FlowRun or a run id). It is the public accessor for a finished run's answers, which processFlowRun returns untouched.
  • flowRunDocument(runId) downloads the company's own service-key-encrypted copy of a run's generated contract and returns the plaintext file bytes (a 404 ApiError until the run generates a document) — the honest completion step (fill → complete → flowRunAnswersflowRunDocument).
  • identity() returns this client's { company_user_id, service_id } from GET /api/company-data/whoami, so a triggerFlowRun binding's company party can bind to company_user_id (the person party's user_id comes from the connection).

Reacting to status changes in the pump

When a recipient acts on a document (e.g. signs it), the feed emits a document_status_changed event. The Change carries documentId and the new status (other change events leave both null):

async function handle(change) {
  if (await alreadyProcessed(change.id)) return;     // idempotency
  if (change.event === 'document_status_changed') {
    await onDocumentStatus(change.documentId, change.status);   // e.g. 'active'
  } else if (change.event === 'field_updated') {
    await store(change.personId, change.slug, change.value);
  }
  await markProcessed(change.id);
}

await client.processChanges(handle);

See docs/model.md for the full Document / Change reference.


Messaging

Your service can hold a conversation with a connected person — the same messaging surface the person already uses, with your service as the counterpart. Two shapes:

  • 1-on-1sendMessage(connectionId, text). End-to-end encrypted: the SDK encrypts one copy to the person's public key and one to your service key before anything leaves the process, so the person reads it in their app and you can re-read your own outbound text. The platform stores ciphertext only.
  • BroadcastbroadcastMessage(text). One plaintext message to every person connected to the service (one body cannot be single-key-encrypted to all of them, exactly as for a broadcast document). It seeds each person's ordinary 1-on-1 thread; their reply comes back end-to-end encrypted.

sendMessage answers 201 with the created message carrying message_id, and returns that id — the value you hand back as the acknowledgement boundary.

Inbound messages arrive on the changes pump / webhook as a message_received event — a person→company message only. A broadcast raises no event of its own.

async function handle(change) {
  if (change.event !== 'message_received') return;
  console.log(change.personId, change.messageBody);   // already decrypted for you

  // Reply on the same connection. personPublicKey rides the event, so no second
  // key lookup is needed.
  await client.sendMessage(change.connectionId, "Thanks — we're on it.", {
    personPublicKey: change.personPublicKey,
  });

  // Acknowledge what you handled. REQUIRED: without it the message stays unread
  // forever, your unread count grows, and the person never sees a read receipt.
  // Sending a reply does NOT acknowledge anything.
  await client.markMessagesRead(change.connectionId, change.messageId);
}

await client.processChanges(handle);

markMessagesRead is bounded by the boundary message: a message that arrived while you were working is not swept, and a repeat is a no-op. The boundary must be a message the person sent on that connection — anything else is refused with ApiError('company_data.ack_boundary_invalid') (400).

// One plaintext announcement to everyone connected to the service.
await client.broadcastMessage("We're closed on Friday.");

Refusals surface as ApiError carrying the platform error_key:

| error_key | Status | Meaning | |-------------|--------|---------| | messages.messaging_not_entitled | 403 | The company's plan does not include messaging. | | messages.not_connected | 403 | The person is not connected to this service. | | messages.messaging_suspended | 403 | Messaging is suspended for this service (or the whole company). | | messages.broadcast_suspended | 403 | Broadcast alone is suspended for this service. | | messages.encryption_required | 400 | A 1-on-1 body was not a valid encrypted wrapper. | | messages.broadcast_audience_too_large | 422 | The service has more connections than a broadcast allows. | | messages.rate_limited | 429 | Too many 1-on-1 messages to the same person. | | company_data.ack_boundary_invalid | 400 | The ack boundary is not a message the person sent on that connection. |


Rate limits

| Endpoint | Limit | Use it for | |----------|-------|-----------| | changes (the pump) | generous | Poll as often as you like — it's a cheap drain-on-fetch queue. | | request-fields, logs | moderate | Occasional reads. | | connections, connection(id), binary /file | heavily limited | Initial full sync + occasional reconciliation only — never as a poll substitute. |

A 429 carries Retry-After. The SDK backs off and retries automatically:

  • The transport (HttpClient) retries a 429 a bounded number of times honoring Retry-After, then surfaces RateLimitError.
  • The connections(...) generator additionally backs off per Retry-After on a surfaced RateLimitError and retries the page a bounded number of times before re-throwing — so it paces itself within the limit instead of hammering.

If you catch a RateLimitError, its .retryAfter is the seconds to wait (or null when the header was absent).

Your client_credentials token requests (/oauth2/token) are on their own rate-limit bucket, separate from person logins — but it is keyed by source IP, not by your client_id, so it is shared with every other client_credentials caller reaching the API from the same address (another service on your network, a second client on the same host). Caching the token, as described under How it's wired below, is what keeps that shared window from being spent needlessly — by you or anyone else behind the same IP. Every rate-limit refusal — this 429, and the platform's 503 when its own limiter store is unreadable — now carries a populated .errorKey, readable off the same RateLimitError/ApiError.


Errors

All from @allus-fyi/company-data. Same taxonomy + names across all six SDKs. Every error extends AllusError, so catch (e) { if (e instanceof AllusError) … } captures the whole taxonomy.

| Error | When | |-------|------| | ConfigError | Missing/invalid config, unreadable key file, or wrong passphrase — at construction (fail fast). | | AuthError | Token fetch/refresh failed (bad client_id/secret, revoked client); or a 401 survives the one automatic refresh-and-retry. | | ApiError | Any non-2xx from the API; carries status, errorKey (the platform error_key, when present), and apiMessage. | | DecryptError | A ciphertext wrapper is malformed, the key is wrong, or the GCM tag mismatches. Surfaces when a value is accessed/decrypted. | | WebhookError | Signature verification failed, or an envelope couldn't be unwrapped/parsed. | | RateLimitError | A 429 from a rate-limited endpoint. Subclass of ApiError (status fixed at 429); carries retryAfter (seconds, or null). |

import {
  Client, AllusError, ConfigError, AuthError, ApiError,
  DecryptError, WebhookError, RateLimitError,
} from '@allus-fyi/company-data';

try {
  const client = Client.fromConfig('allus.json');
  for await (const conn of client.connections()) { /* … */ }
} catch (e) {
  if (e instanceof ConfigError) { /* fix the config / key file */ }
  else if (e instanceof RateLimitError) { await wait((e.retryAfter ?? 60) * 1000); }
  else if (e instanceof ApiError) { log(e.status, e.errorKey, e.apiMessage); }
  else throw e;
}

See docs/errors.md.


How it's wired

Everything below is what the SDK hides so your code only ever sees conclusions.

Auth / token. An HttpClient owns a client_credentials-only token. On the first call (or when the cached token nears expiry) it POSTs client_id/client_secret to {api_url}/oauth2/token and caches the bearer token + its expiry; refresh is automatic. A mid-flight 401 triggers exactly one refresh-and-retry, then AuthError. The token is scoped server-side to one service, so every call is implicitly that service's data. The transport is over Node's global fetch by default, but injectable (HttpTransport) for tests.

Regions. The configured api_url is the platform's global front door and also the starting point for every request, including the token request. A client_credentials token is minted at your company's home region, and the token response names that region's base in an api_url member — the SDK stores it and sends every subsequent request there, token requests included, because the token is valid only at that region and a company that moves region is followed by the next mint. A data call that still reaches the front door is refused with 421 + error_key: region.rebase_required, carrying the same api_url; the SDK stores it and retries the call exactly once. The SDK does not validate a server-returned api_url against anything — it stores the base the server names and uses it. An absent or empty api_url is never stored and the response surfaces as the error it is.

Slug resolution. requestFields() is fetched once and cached; its slug→type map types every value (so address parses to an object, photo becomes a lazy binary handle, etc.). The connection/changes endpoints return values keyed by your request slug — the person's source field is dropped server-side and never reaches the SDK.

Decryption (zero-knowledge). The service private key is loaded once at construction from the configured encrypted PEM + passphrase (crypto.createPrivateKey({ key, passphrase }) — PBES2 handled by OpenSSL). A decryptValue closure over it is handed to every model factory and the pump — the key never appears in a method signature. Each value is a hybrid wrapper ({"_enc":1,"k":rsa_oaep_sha256(aesKey),"iv":…,"d":aes256gcm(…)}); the SDK RSA-OAEP-SHA256 unwraps the AES key (privateDecrypt({ …, oaepHash: 'sha256' }) — Node defaults to SHA-1, so the SHA-256 pin is essential), then AES-256-GCM decrypts the payload (the 16-byte tag is the last 16 bytes of d). The platform only ever holds ciphertext — it never sees your plaintext.

Binary fetch. A binary value is a lazy BinaryHandle over a slot-keyed value_url. On .bytes()/.save() it GETs that file endpoint and returns the file bytes. The endpoint answers in one of two shapes depending on whether the person's source field is private: a {"encrypted":true,"value":<wrapper>} JSON envelope that the same service-key decrypt turns into a JSON file-envelope whose data URI base64-decodes to the file, or — for a plaintext source — the file's own Content-Type and the bytes themselves. The SDK classifies on Content-Type alone and never sniffs the body: mistaking a wrapper for file bytes would silently write ciphertext to disk as if it were the document, while mistaking bytes for a wrapper fails loudly at the parse, so an absent Content-Type is treated as the JSON shape. (Slot-keyed, never source-field-keyed.)

XML, safely. When format: "xml", responses (and webhook bodies) are parsed by a small, XXE-safe hand-written parser: no DOCTYPE/DTD processing, no custom or external entities — those vectors are simply absent, and a DOCTYPE / unknown entity is rejected. HMAC verification is always over the raw bytes, never the parsed tree.

The drain-on-fetch feed. processChanges delegates to a Pump wired to a fetchChanges closure (GET /changes?limit=, returning raw ciphertext events) and a decrypt closure (builds a typed Change). Because the fetch deletes the rows it returns, the pump persists each batch to the durable file buffer (ciphertext at rest) before delivery, acks per-item after your handler succeeds, and replays the buffer on restart — see The changes pump.


Status

Crypto parity gate: the decryption core is verified against the shared cross-language decryption vector — PEM-load (PBES2 / PBKDF2-SHA256 / AES-256-CBC, 100k iters), text decrypt, and the binary decrypt → envelope → inner-bytes hash — plus an independent OpenSSL cross-check (anti-circularity). The full test suite (config, crypto, http/auth, models, the crash-safe pump, webhooks, and the XXE-safe XML parser) is green under npm test.

Sign in with allme (OAuth, #195)

import { OAuthClient } from '@allus-fyi/company-data';

const oauth = OAuthClient.fromConfig('idw-config.json');
const url = oauth.authorizeUrl('signin', { state, codeChallenge });   // the button target
// ...user approves; your redirect receives ?code=...
const { user, mode, values, values_cipher, attestations } = await oauth.completeSignIn(code, verifier);

Modes: signin | one_time (claim values decrypted for you) | connect | 2fa_enroll (opt a person into 2FA — see below). pollResult(state) drives the detached mode.

#498 — a claim IS a request field. You describe what you need and the person picks which of their own fields answers it; you never name a field. A claim carries a mandatory unique name (everything that comes back is keyed by it — values, values_cipher, attestations, and their stored choice for a repeat login), a field type, an optional suggested slug, required, and verified ("only a #311-verified answer will do"). A nameless or duplicate claim raises a config error at the call rather than failing at the API. verified is accepted only on the OIDC flow and only for a type allme can verify (today email); elsewhere it is refused with invalid_request rather than quietly dropped.

verifiedMaxAgeDays narrows a verified claim to a RECENT verification, and the merge is tighten-only: the app's registered configuration is a FLOOR, a request may only tighten it, and the effective limit is the minimum of the two stated ages. An omitted age tightens nothing — omitting it sends nothing at all, never an explicit null — and a value below 1 raises ConfigError at the call.

The sign-in result carries values, values_cipher and attestations.

  • sub is the person's share code and equals share_code — byte-identical to the id_token's sub. display_name is gone: ask for a name claim and read the value under that key.
  • values_cipher is an additive sibling of values, keyed the same way: the raw app-key ciphertext wrapper each plaintext value was decrypted from, exactly as userinfo delivered it. Lets you show that a value really came from encrypted delivery rather than trusting it verbatim. Empty for a mode/claim that carries no ciphertext (signin, or plaintext delivery) — that emptiness is the honest answer.
  • attestations is an additive sibling map keyed by the same claim name, present only for a verified claim under encrypted delivery. Each entry carries a verified boolean the SDK computes itself, in constant time, over the plaintext it just decrypted — plus the raw hash/salt/verifiedAt/verifiedExpiresAt. A slug ABSENT from the map is "not attested", never "wrong" (treat that value as unverified); an entry present with verified false is a MISMATCH and you must reject the value. verifiedAt attests the value as verified at that moment, not verified today; verifiedExpiresAt is when that verification lapses on its own (null = it does not), and an expired attestation is unverified — the computed verified already reads false once it has passed.

resolveUserinfo(accessToken, fallbackMode?) is the second half of completeSignIn — the userinfo read + decrypt + attest, without the token exchange — for a caller whose exchange already ran through a different client (a standards-only third-party OIDC library, say, that verified the id_token itself but cannot read a claim value the id_token never carries). Config-only key handling applies exactly as it does to completeSignIn: you pass no key or passphrase, only the access token you already hold. Returns the identical shape (values, values_cipher, attestations) and carries the same mismatch-rejection duty on the caller. completeSignIn is implemented on top of this method. fallbackMode is used only when userinfo itself omits mode — pass the mode your own token response carried, or omit it if you have none.

2FA by allme (#436, #481)

Ask a connected person to approve a login inside the allme app. On the same service data client (no new config), via the twoFactor sub-client:

import { Client } from '@allus-fyi/company-data';

const client = Client.fromConfig('allus.json');

// Raise a challenge. `idempotencyKey` is REQUIRED — a repeat with the same key within the TTL returns the
// SAME challenge and sends no second push. `context` is plain text shown on the person's card.
const ch = await client.twoFactor.challenge('2I6UF3', {
  idempotencyKey: 'login-8f3c1a',
  context: 'Sign-in from Chrome',
});
if (ch.matchingDigits) {                       // number matching is on for this service
  showOnLoginPage(ch.matchingDigits);          // the person types these back into the app; the server checks them
}

// Wait for the terminal outcome — polls result() for you (defaults: 600s timeout, 2s interval),
// rejects with ApiError on timeout.
const res = await client.twoFactor.waitForResult(ch.challengeId);   // or result(ch.challengeId) to poll once yourself
if (res.status === 'approved') grantLogin();
  • Burn-on-read. The first read of a terminal state (approved | denied | expired | revoked) delivers it and burns it — a later read is gone. Read it once and persist your own outcome; waitForResult returns that first terminal read and never re-reads a consumed challenge.
  • Webhook variant. The 2fa_challenge_completed change/webhook carries the same terminal status, so a webhook consumer need not poll. Expiry fires no webhook/Change — only approved/denied/revoked reach the feed, so a lapsed challenge is observable only by polling.
  • Enrollment. Only an enrolled person can be challenged (an un-enrolled share_code is 404). Enrollment is a one-time consent on the web.allme.fyi/auth surface via the OAuth helper's 2fa_enroll mode — a redirect button (oauth.authorizeUrl('2fa_enroll', { state })), or server-to-server with { responseMode: 'detached' } + pollResult(state), which returns { enrolled: true, state } once the person confirms.
  • Errors. 404 (unknown / not-enrolled share code). A 429 is either the plain rate limit (retried with backoff → RateLimitError) or twofa.pending_cap (too many challenges already open for this person) — the latter surfaces immediately as ApiError and is never retried, since a retry cannot clear it.