Developer guide

Inventory sync — the host integration guide

Your app knows when a job uses materials and when stock is bought. TheGuyBooks keeps the books. This guide shows how to send that stock movement into each client’s books, safely and exactly once, with the @theguysapp/books-host-sdk package or any HTTP client.

Whose stock is it?

Read this first. Every call names a tenant in its path: /v1/tenants/{tenantId}/inventory/…. That tenant is the shelf that moves. When a job used the Boss’s stock, post to the Boss’s tenant. When it used a crew member’s own stock, post to that crew member’s tenant.

  • One host key may post to many tenants — every client you serve.
  • One document never spans two tenants. A job that used both shelves posts TWO documents, one per tenant, each with its own source_event_id.
  • Mappings are per tenant too. The same item in your app maps to the Boss’s item in the Boss’s books and to the crew member’s item in theirs.
  • Your app decides which shelf was used. Books never guesses.

A job that used 2 bags from the Boss’s shelf and 1 bag from the crew member’s truck:

await books.usage.post(bossTenantId, {
  source_event_id: sourceEventId('job', jobId, 'usage-boss-shelf'),
  occurred_on: '2026-10-01',
  lines: [{ external_id: 'mulch-bag', qty: '2' }],
});

await books.usage.post(crewTenantId, {
  source_event_id: sourceEventId('job', jobId, 'usage-crew-shelf'),
  occurred_on: '2026-10-01',
  lines: [{ external_id: 'mulch-bag', qty: '1' }],
});

Install and connect

npm install @theguysapp/books-host-sdk
import { BooksHost, BooksRefusal, BooksTransportError, sourceEventId } from '@theguysapp/books-host-sdk';

const books = new BooksHost({
  baseUrl: process.env.BOOKS_BASE_URL!,
  key: process.env.BOOKS_HOST_KEY!,
});
RingbaseUrlKeys start with
Sandbox — build and test here firsthttps://sandbox.theguybooks.comtgb_test_
Production — real bookshttps://theguybooks.comtgb_

The sandbox is the preprod ring, with test data only: nothing there is real money, and it answers the /v1 API and nothing else. Want to test against fake books first? A sandbox workspace and a test key are provisioned on request: reply to your inventory install email, or write to support@theguys.app, and we set them up within one business day.

  • Production keys come only from your host console: Set up → Advanced: API & automation → Inventory keys. We never email a key.
  • A key works only on the ring that minted it: a tgb_test_ key on the sandbox, a tgb_ key on production.
  • A key has one scope, chosen when you mint it: an inventory key reaches the inventory calls and nothing else. The seven calls below need the inventory scope. You can hold 2 live inventory keys at a time, so you can roll one without downtime.
  • Each client’s tenantId is listed under Your client ids on the same console page. Store it next to the client in your app.
  • The same client also creates your clients’ books and brings each buyer in, with a workspace key: tenants.create, tenants.createOnboardingLink, tenants.get, tenants.waitForClaimed, tenants.updateOwner and tenants.list. That road is written up step by step at https://theguybooks.com/docs/host-setup#connecting-your-clients.

The seven calls

Call 1, what is on the shelf: GET /v1/tenants/{tenantId}/inventory/items?as_of=YYYY-MM-DD. as_of is required. Each tracked item comes back with item_id, name, unit_label, external_id (your mapping, or null), available_units (a decimal string), as_of, pending_posts (your documents still queued that name it) and stale. The answer carries binding: false — see The contract below. It never carries cost or value.

const shelf = await books.items.available(tenantId, { asOf: '2026-10-01' });
for (const item of shelf.items) {
  console.log(item.external_id, item.available_units, item.unit_label, item.pending_posts);
}

Call 2, say which Books item your item is: PUT /v1/tenants/{tenantId}/inventory/mappings/{externalId} with name, unit_label and an optional unit_factor (how many Books units one of your units is; default 1). You PROPOSE; the tenant’s OWNER confirms it in Books. Books never matches on name by itself. The answer’s status is proposed, confirmed or refused; a confirmed one carries item_id, and unit_mismatch: true when the owner confirmed a different unit than the one you sent.

const mapping = await books.mappings.propose(tenantId, 'mulch-bag', {
  name: 'Mulch, 2 cu ft bag',
  unit_label: 'bag',
  unit_factor: '1',
});
// mapping.status stays 'proposed' until the owner confirms it.

Calls 3 and 4, learn what the owner decided: GET /v1/tenants/{tenantId}/inventory/mappings/{externalId} reads one mapping, and GET /v1/tenants/{tenantId}/inventory/mappings?status=&cursor=&limit= pages through all of yours, newest proposal first (status is optional: proposed, confirmed or refused; limit 1 to 100, default 50). Each mapping carries its status, the owner’s item_id once confirmed, the host_item_name, host_unit_label and unit_factor, and proposed_at, confirmed_at and updated_at. This is how you learn a confirm: read the mapping, never propose again just to find out. An id you never proposed answers 404 mapping_not_found.

const current = await books.mappings.get(tenantId, 'mulch-bag');
if (current.status === 'confirmed') {
  // The owner chose current.item_id: posts naming mulch-bag can post now.
}

// Or everything still waiting for the owner:
const waiting = await books.mappings.list(tenantId, { status: 'proposed' });

Call 5, stock used up: POST /v1/tenants/{tenantId}/inventory/usage — ONE document per call: source_event_id, occurred_on (YYYY-MM-DD), an optional memo, and 1 to 100 lines of external_id and qty (a decimal string greater than zero). Books answers 202 { event_id, status: queued } and posts it moments later.

const queued = await books.usage.post(tenantId, {
  source_event_id: sourceEventId('job', jobId, 'usage'),
  occurred_on: '2026-10-01',
  memo: 'Job 1042, back garden',
  lines: [{ external_id: 'mulch-bag', qty: '3' }],
});
// queued.event_id is Books’ id for this document.

Call 6, stock bought: POST /v1/tenants/{tenantId}/inventory/purchases — the same shape, and each line also carries amount, the cost you actually paid for the line, as a decimal string. amount is the money Books posts. rate is optional: send it when you have an exact price per unit, and then qty × rate must equal amount. Leave it out when you only know the quantity and the total — 300 units for 100.00 has no exact four-place price — and Books shows amount ÷ quantity as the line’s rate. It posts as an expense paid from the account the owner chose, and answers 202 like usage.

await books.purchases.post(tenantId, {
  source_event_id: sourceEventId('order', orderId, 'purchase'),
  occurred_on: '2026-10-01',
  lines: [
    { external_id: 'mulch-bag', qty: '10', rate: '4.50', amount: '45.00' },
    { external_id: 'stake', qty: '300', amount: '100.00' }, // no exact price: leave rate out
  ],
});

Call 7, what happened: GET /v1/tenants/{tenantId}/inventory/posts?cursor=&limit= pages through your posts for the tenant, newest first (limit 1 to 100, default 50). Each post has its status: queued, posted (with every document_id and journal_entry_id it wrote), refused (with reason, message and details) or voided (a delete you sent — voided names the version it took back; see Idempotency and corrections). next_cursor fetches the next page, and is null on the last one. There are no webhooks in v1, so you poll this.

const page = await books.posts.list(tenantId, { cursor, limit: 100 });
for (const post of page.posts) {
  console.log(post.source_event_id, post.correction_seq, post.status, post.refused?.reason);
}

// Or let the SDK walk the pages for you (a long-running worker):
for await (const outcome of books.posts.follow(tenantId, { cursor })) {
  await saveOutcome(outcome);
}

On its own, posts.follow never stops: after the last page it polls for new outcomes until you stop iterating. A cron job or a serverless function must end its run, so give it a stop: maxPages (end after that many pages), until(page) (end after the page it returns true for) or signal (an AbortSignal; aborting ends the walk before its next request, or at once while it waits between polls). Every outcome already read is yielded before it ends, and ending is a normal end of the loop, never an error. The next run reads from the newest page again; store outcomes keyed by event_id, so seeing one twice is harmless.

// A cron run: read the newest pages, then stop before the platform’s time limit.
for await (const outcome of books.posts.follow(tenantId, { maxPages: 5, signal: AbortSignal.timeout(50_000) })) {
  await saveOutcome(outcome);
}

The outbox pattern

Never call Books in the middle of your own database transaction. If your transaction rolls back after the call, Books has a document your app forgot. Use an outbox instead — four steps:

  • In the SAME transaction that finishes the job, write an outbox row: the tenant, the document body and its source_event_id.
  • After the commit, a worker sends each unsent row to Books.
  • Store what Books answered on the row: event_id on success, the refusal code on a refusal.
  • Poll the outcomes and store each post’s final status on its row.
// db is your own database layer.
for (const row of await db.outbox.unsent()) {
  try {
    const answer = await books.usage.post(row.tenantId, row.body);
    await db.outbox.markSent(row.id, answer.event_id);
  } catch (err) {
    if (err instanceof BooksRefusal) {
      await db.outbox.markRefused(row.id, err.code, err.message);
    } else if (err instanceof BooksTransportError) {
      // Leave the row unsent. The next run sends the SAME body again.
    } else {
      throw err;
    }
  }
}

Because every row carries its own source_event_id, sending a row twice is safe — see the next section.

Idempotency and corrections

source_event_id is required on every write. Make it stable — build it from your own record, never from the clock or a random number. sourceEventId('job', jobId, 'usage') does that for you.

  • Same source_event_id, same body: Books answers 200 with that document’s current outcome. Nothing posts twice.
  • Same source_event_id, different body: 422 idempotency_mismatch. Use a new id for a new document, or the next correction_seq to change or delete this one.

You can change OR delete a document after you sent it. To change it, send the SAME source_event_id with correction_seq set one higher (the first version is 1) and the WHOLE new document. Books takes back what the earlier version posted and posts the new version in full, whether or not the date moved. The books end up as if only the new version had ever been sent.

// Job 1042 really used 4 bags, not 3.
await books.usage.post(tenantId, {
  source_event_id: sourceEventId('job', jobId, 'usage'),
  correction_seq: 2,
  occurred_on: '2026-10-01',
  lines: [{ external_id: 'mulch-bag', qty: '4' }],
});

To delete it — the job or the purchase was deleted in your app — send a VOID: the same source_event_id, the next correction_seq, void: true, and nothing else (no lines, occurred_on or memo). Books takes back what the earlier version posted and posts nothing. On the outcomes cursor that version reads voided, and voided names the version it took back (null when nothing was standing — for example, the earlier version had been refused).

// Job 1042 was deleted in your app.
await books.usage.void(tenantId, {
  source_event_id: sourceEventId('job', jobId, 'usage'),
  correction_seq: 3,
});

// The same for a deleted purchase:
await books.purchases.void(tenantId, {
  source_event_id: sourceEventId('order', orderId, 'purchase'),
  correction_seq: 2,
});
  • A correction or a void waits for the version before it to finish. Sent too soon, it answers 409 previous_version_pending — retry it a few seconds later.
  • If Books cannot take the earlier version back — its stock is already used (insufficient_stock) or its date is in a closed period (period_closed) — the correction or void is refused as a whole, with details.phase set to void_previous and details.documentId naming the document that could not be taken back. The earlier version stays posted, every document of it, and nothing of the new version posts.
  • A void is a version like any other: the same void again answers 200 with its outcome, and a void with correction_seq 1 is refused 400 invalid_body — there is nothing to void yet.
  • When the owner fixes a refusal in Books and presses Retry, Books posts the same body as the next version. You will see it on the outcomes cursor with a higher correction_seq.
  • A revoked key stops new posts at once. It destroys nothing: documents Books already accepted still post.

Refusals, and who fixes them

Every error has one shape: { error, message, details }. The SDK throws it as BooksRefusal with code, message, details and status. Some answers come back on the call itself; others come back later, as a refused post on the outcomes cursor.

Who fixes it. Most refusals are fixed by the tenant’s OWNER inside Books, under Books → Inventory → Connected apps. Your support team is their first stop: tell the owner what the refusal says and where to fix it. Never send them to TheGuyBooks.

What each client’s owner does in their books, under Books → Inventory → Connected apps — a list you can forward:

  • Turn stock tracking on in their books.
  • Track each item your app will move as inventory.
  • Confirm each mapping your app proposes — the right item and the unit factor — or refuse it.
  • Choose the account your purchases are paid from.
  • When stock runs short, record the missing purchase or count.
  • When an item has no cost yet, record a purchase or an opening balance.
  • When a bank or card line may already be the same purchase, decide: the same purchase, or a different one.
  • Reopen a closed period if a document has to post on a date inside it — only the owner can.
  • After fixing any of these, press Retry on the document under Needs attention.

On the call itself:

CodeWhat it meansWho fixes it
invalid_body (400)A field of the body is wrong. details.field names it and details.issue says why — for example line_arithmetic_mismatch when a purchase line sends a rate and qty × rate is not amount.You — fix the document and send it again.
invalid_report_params (400)A query value is missing or wrong, such as as_of on the items read or a cursor Books did not issue.You.
body_too_large (413)The body is over the size limit.You — send smaller documents (at most 100 lines each).
idempotency_mismatch (422)This source_event_id and correction_seq were already used with a different body. On POST /v1/tenants: this external_id already names a client created with a different body (details.tenant_id).You — a new id, or the next correction_seq; for a client, a new external_id, or the identical body to retry.
previous_version_pending (409)The version before this correction has not finished posting.You — retry in a few seconds.
unauthorized (401)The key is missing, wrong, revoked or expired.You — use a live key from your console.
insufficient_scope (403)The key cannot reach this call.You — use an inventory key (the client calls need a workspace key).
host_suspended (403)Your host account is paused.You — in your host console.
tenant_not_found (404)That tenant is not one of your clients.You — check the id under Your client ids.
mapping_not_found (404)You never proposed a mapping for that item id in these books (reading one mapping).You — propose it first (call 2).
tenant_suspended (403)That client’s books are paused.You — reinstate the client when you are ready.
not_found (404)There is no such call.You — check the path.

On the client calls — creating a client’s books, its onboarding link and its owner:

CodeWhat it meansWho fixes it
tenant_removed (409)The client was removed from your roster. details.tenant_id names it.You — restore it in your console, or create the client again with a new external_id.
tenant_claimed (409)The owner already reached these books, so no link can be made and the owner’s address can no longer be changed.Nobody — send the owner to your sign-in page.
owner_missing (409)The client has no owner login yet, so no link can be made.You — set the owner with PUT /v1/tenants/{tenantId}/owner. An address already on file is retried by itself.
return_url_not_allowed (400)return_url is not on a return address you registered.You — register it in your console: Set up → Advanced: API & automation → Return addresses.
onboarding_link_rate_limited (429)More than 10 links for one client in the last hour.You — wait Retry-After seconds (also details.retry_after); the SDK never waits this one out for you.

Later, as a refused post (refused.reason):

CodeWhat it meansWho fixes it
unmapped_itemA line names an external_id with no mapping in this tenant’s books.You propose the mapping; the owner confirms it and presses Retry.
mapping_not_confirmedThe mapping is proposed but the owner has not confirmed it yet.The owner — confirm it under Mappings, then Retry.
item_not_trackedThe Books item the mapping points at does not track stock.The owner.
inventory_not_enabledStock tracking is switched off in these books.The owner.
insufficient_stockThe shelf would go below zero. details.shortages lists each short item: itemId, itemName, requestedQty, onHandQty, unitLabel and dipDate (the first day it would dip). details.phase is void_previous when the shortage came from taking back the earlier version of a correction.The owner — record the missing purchase or count, then Retry.
date_before_costing_startThe date is before these books started tracking stock costs.The owner, or you if the date was wrong (send a correction).
invalid_quantityA quantity rounds to zero in Books once the mapping’s unit_factor is applied.You — check the quantity; the owner checks the mapping’s unit.
no_value_changeThe item has no cost in the books yet, so using it changes nothing.The owner — record a purchase or opening balance, then Retry.
host_purchase_account_unsetThe owner has not chosen which account your purchases are paid from.The owner — choose it in Connected apps, then Retry.
invalid_pay_from_accountThe chosen pay-from account cannot pay a purchase.The owner — choose another account, then Retry.
possible_duplicate_feed_lineA bank or card line already in the books fits this purchase; details names it: feed_entry_id, occurred_on, amount, posted_by (rule, person or unknown) and the rule.The owner decides: the same purchase (nothing to do) or a different one (Retry posts it).
period_closedThe date falls in a period the owner has closed.Nobody on your side. Only the owner can reopen the period — or you post the change on an open date.

The one with no fix on your side. period_closed is the only refusal your app cannot work around by changing what it sends for that date. The owner closed those books on purpose; tell them, and let them decide.

  • refused.reason is an open set: Books may add a code. Handle one you do not know as a refusal for the owner to look at in Books, and show its message. The spec types details for insufficient_stock and possible_duplicate_feed_line; every other code carries a plain object.
  • unit_mismatch is not a refusal. A mapping answer carries unit_mismatch: true when the owner confirmed the item with a different unit. Tell the owner; only they can re-confirm it.
  • You will never see mixed_cogs_accounts: Books splits a document by cost account by itself.
  • rate_limited (429) and rate_limiter_unavailable (503) are not refusals. Wait, then send the same request again — see Rate limits and retries.
  • internal_error (500) is a failure on Books’ side, not a refusal. Retry it like a network failure, with the same source_event_id.

The contract

What Books promises, and what it does not. These sentences are the rules; build to them.

Not a reservation. Every items answer says binding: false and carries this sentence: “the number was true as of as_of; it is not a reservation; a post against it may still be refused.”

  • Read availability when you need it, and never cache it. A number from an hour ago is not a number about now.
  • A post into a closed period is refused, and there is no host-side remedy (see Refusals).
  • The API is /v1. Changes to it are additive only: new fields and new calls, never a renamed or removed one. A breaking change would be a new /v2.

Rate limits and retries

  • Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds). Slow down before RateLimit-Remaining reaches zero.
  • The budget is per host, shared by all your keys. Two keys never buy two budgets.
  • Over the budget: 429 rate_limited with Retry-After. Wait that long, then send the same request again.
  • If Books cannot reach its rate limiter, writes answer 503 rate_limiter_unavailable with Retry-After. Nothing was recorded, so nothing is lost: send it again later. Reads stay open.

Retry a network failure or a 5xx answer with a growing wait between tries, and ALWAYS with the SAME source_event_id — that is what makes the retry safe. NEVER retry a refusal: the same document gets the same answer every time. Fix the cause, or send a correction.

The SDK does all of this for you, and throws BooksTransportError only after its retries run out.

Keys and secret handling

  • Keep the key on your server only. Never put it in a page your visitors load, a mobile app, or a client bundle.
  • Keep it in your environment or a secret manager. Never commit it to a code repository.
  • Each key is shown ONCE, when you mint it. Copy it straight into your secret store.
  • Think a key leaked? Roll it in your console. Roll mints a new key and keeps the old one working for the overlap you pick (1h, 24h, 7d) while you switch over; then the old one stops.
  • Build against the sandbox with a tgb_test_ key. Use a tgb_ key only on production.
# .env on your server — never in the browser
BOOKS_BASE_URL=https://sandbox.theguybooks.com
BOOKS_HOST_KEY=tgb_test_…

The conformance check

One command proves your key, your base URL and a tenant are wired correctly:

BOOKS_HOST_KEY=tgb_test_… npx @theguysapp/books-host-sdk conformance --base-url https://sandbox.theguybooks.com --tenant <tenantId>
  • It only READS and triggers REFUSALS. It posts nothing by itself, so it is safe on production too.
  • It reads the items, proposes a mapping and reads it back, sends a usage post that is refused, sends it again to prove the replay, sends a different body to prove the 422, and reads the outcomes cursor and a page of mappings.
  • It leaves things behind in the tenant’s books: the proposed mapping called conformance-probe, and two refused test documents in Needs attention (under Connected apps).

Tell the owner: refuse the probe, never confirm it. The owner should REFUSE the conformance-probe mapping in Connected apps and never confirm it. A confirmed probe followed by Retry on one of the refused test documents WOULD post a real usage document.

The spec and the changelog

GET /v1/openapi.json (no key needed) is the OpenAPI 3.1 document of these calls, generated from the same code that checks every request. Point any OpenAPI tool at it to build a client in your own language.

Every change to /v1, newest first:

  • 2026-10-04 — Host-auto-provision: a host's software can now create a client's books safely on a retry, bring the buyer into them, and read where they stand. (1) POST /v1/tenants REQUIRES external_id — your own id for the client, 1-255 characters (400 invalid_body naming it when missing). The same external_id with the same body answers 200 with the tenant as it stands now and the header Idempotent-Replayed: true, creating nothing; with a different body 422 idempotency_mismatch (details.tenant_id); for a removed tenant 409 tenant_removed (details.tenant_id). The answer carries external_id. (2) POST /v1/tenants takes owner_invite: email (the default — the owner is emailed at create) or link (the email is held for your own redirect and sent only if the client is still not claimed 15 minutes later). (3) owner.status answers invited whether or not the owner's address already had a login (that owner is emailed a note instead of a set-up link); granted_existing is no longer sent and stays in the enum only so older clients still parse. (4) GET /v1/tenants/{tenantId} (new) answers the tenant with external_id and onboarding { state: owner_pending | invited | started | claimed, owner_email, invited_at, link_issued_at, claimed_at }; GET /v1/tenants gains external_id and onboarding.state per tenant. (5) POST /v1/tenants/{tenantId}/onboarding-links (new) answers 201 { url, expires_at }: a one-use link under your brand, good for ten minutes, that ends every older one; redirect your buyer to it and never send it anywhere else. Optional return_url must be on an origin you registered in the host console (400 return_url_not_allowed); refusals 409 tenant_claimed, 409 owner_missing, 409 tenant_removed, 403 tenant_suspended, 429 onboarding_link_rate_limited (ten an hour per client, Retry-After). Reaching return_url does not mean the client is set up — read the tenant until onboarding.state is claimed. (6) PUT /v1/tenants/{tenantId}/owner (new) corrects the owner address until the client is claimed (409 tenant_claimed after): every live link stops working and the new owner is emailed.
  • 2026-10-02 — Five additions for hosts, every earlier request and answer unchanged. (1) A purchase line's rate is optional: amount is the money Books posts; sent, qty × rate must still equal amount (400 line_arithmetic_mismatch); left out, the line shows amount ÷ qty per Books unit, rounded half up to four places. (2) GET …/inventory/mappings/{externalId} reads where one mapping stands (status, the owner's item_id, what the host proposed, proposed_at, confirmed_at, updated_at; 404 mapping_not_found for an id never proposed), and GET …/inventory/mappings?status=&cursor=&limit= pages through the host's mappings, newest proposal first; an inventory key reaches both. (3) A refused post's details are typed for insufficient_stock (shortages of itemId, itemName, requestedQty, onHandQty, unitLabel, dipDate, and phase void_previous with documentId on a take-back) and possible_duplicate_feed_line; reason is an open set, and every other code keeps a free details object. (4) Written down, not changed: when a correction's or void's take-back of the earlier version is refused (insufficient_stock or period_closed, details.phase void_previous), the earlier version stays posted, whole, and nothing of the new version posts. (5) GET …/inventory/posts is unchanged; SDK 0.1.3 now passes its limit (1-100) and lets posts.follow stop (maxPages, until, signal), so a cron host can end its run.
  • 2026-10-02 — This spec now describes the whole API: the nineteen older /v1 routes (tenants, suspend and reinstate, config, hosts/me usage, subscription and domains, seed-accounts, accounts, customers, classes, qbd-import, import/{type}, reports, books and embed-token) are generated from the schemas that now validate them, every success and every refusal documented exactly as answered. Every answer those routes gave before is unchanged, refusals included, except one: a body field of the wrong JSON type that a route used to ignore or quietly coerce (for example a number for name on POST /v1/tenants, or the string "false" for dry_run on an import) is now refused 400 invalid_body naming the field, and nothing is written. A null on an optional field still means not sent.
  • 2026-10-01 — The void correction: POST …/inventory/usage and …/purchases also accept { source_event_id, correction_seq (2 or more), void: true } with no lines, occurred_on or memo — the host deleted the document. Books voids what the earlier version posted and posts nothing; the outcome reads status voided, with voided { correction_seq, document_id, journal_entry_id } naming the version it took back (null when nothing was standing). A void is a version like any other: the same body again answers 200, a different body 422 idempotency_mismatch, and before the previous version finished 409 previous_version_pending.
  • 2026-10-01 — GET /v1/openapi.json added — the generated OpenAPI 3.1 spec of the pipeline routes; no key required
  • 2026-09-30 — POST /v1/tenants/{id}/inventory/purchases is live: it queues one purchase document and answers 202 { event_id, status: queued }, with the same idempotency, 422 and 409 answers as usage. A purchase posts as an expense paid from the account the tenant’s owner chose (refused host_purchase_account_unset until one is set, and invalid_pay_from_account if the chosen account cannot pay a purchase).
  • 2026-09-30 — The bank-feed duplicate gate: a purchase is never posted twice with its own bank or card charge. When a bank line already posted in the books (by one of the tenant’s rules, a person, or before the writer was recorded) fits the purchase (same pay-from account, exact amount, within the bank feed’s match window), the purchase is refused possible_duplicate_feed_line with details naming that feed entry (posted_by rule, person or unknown), and the owner decides — if it is a different purchase, the owner’s retry posts it as a new version; when the purchase posts first, the later bank line is offered to the owner as a match instead of being posted.
  • 2026-09-30 — An owner may retry a refused inventory document in Books after fixing its cause; the retry is the next version (correction_seq + 1) of the same body, and GET …/inventory/posts shows it like any other version.
  • 2026-09-30 — GET /v1/tenants/{id}/inventory/posts?cursor=&limit= pages through this host’s inventory posts for the tenant, newest first (limit 1-100, default 50): each post’s status (queued, posted with every document it wrote, or refused with reason, message and details) and an opaque next_cursor.
  • 2026-09-30 — POST /v1/tenants/{id}/inventory/purchases validates a purchase document (qty × rate must equal amount, 400 line_arithmetic_mismatch) and answers 404 until host purchases are switched on.
  • 2026-09-30 — POST /v1/tenants/{id}/inventory/usage queues one usage document (source_event_id, occurred_on, memo, correction_seq, 1-100 lines of external_id and qty) and answers 202 { event_id, status: queued }; the same body again answers 200 with its current outcome, a different body 422 idempotency_mismatch, and a correction before the previous version finished 409 previous_version_pending.
  • 2026-09-30 — PUT /v1/tenants/{id}/inventory/mappings/{externalId} proposes which Books item a host item is ({ name, unit_label, unit_factor }); the owner confirms it in Books. Answers { status: proposed | confirmed | refused, item_id }, 201 on a new proposal; a confirmed mapping whose unit differs answers unit_mismatch: true.
  • 2026-09-30 — GET /v1/tenants/{id}/inventory/items?as_of=YYYY-MM-DD lists the tenant’s tracked items with available_units as of that date, this host’s external_id and pending_posts; binding: false — the number is not a reservation. as_of is required (400 invalid_report_params).
  • 2026-09-30 — A write (POST, PUT, PATCH, DELETE) answers 503 rate_limiter_unavailable with Retry-After: 30 when the rate limiter cannot be reached; nothing is recorded, so retry. Reads stay open.
  • 2026-09-30 — Every call after authentication also counts against a per-host budget of 300 requests a minute, shared by all of the host’s keys; a breach answers 429 rate_limited with Retry-After.
  • 2026-09-30 — Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) for the calling key’s per-minute budget whenever the rate limiter is running.
  • 2026-09-30 — A key may expire (a roll in the console gives the old key a dated end); an expired key answers 401 like a revoked one. Keys minted off production start tgb_test_.
  • 2026-09-30 — New key scope inventory: it reaches only the five /v1/tenants/{id}/inventory/… routes (answering 404 until they ship) and 403 insufficient_scope everywhere else.
  • 2026-09-30 — Every response carries Cache-Control: no-store.
  • 2026-09-30 — POST /v1/tenants/{id}/events answers a reused source_event_id with a different body as 422 idempotency_mismatch (was 409 invalid_idempotent_request).
  • 2026-09-30 — Every write refuses a body over its size limit with 413 body_too_large (256 KiB; 8 MiB on the two QBD import routes, replacing their payload_too_large body check).
  • 2026-09-30 — Malformed JSON, a non-object body, or an invalid field answers 400 invalid_body naming the field, instead of being read as an empty body.
  • 2026-09-30 — POST /v1/tenants/{id}/events: occurred_at, when sent, must be an RFC 3339 date-time with an offset or Z, or a YYYY-MM-DD date.
  • 2026-09-30 — POST /v1/tenants/{id}/events: source_event_id is required (1-255 characters, no control characters, no surrounding whitespace).
  • 2026-09-30 — POST /v1/tenants/{id}/events stamps source "host" on every event and refuses a body that names a source.

Setting up your branded login or your own domain? That is the host setup guide.