# Tiyo Pay Gateway — LLM Integration Guide

> **Audience:** code-generation LLMs tasked with integrating a POS, checkout
> form, subscription engine, or back-office tool with the Tiyo Pay Gateway.
> Single-file, self-contained, version-dated. Paste this whole document into
> your LLM's context and it has everything it needs to write working code.

- **Document version:** 2026-04-30 (rev 14 — fixes the manual `/v1/invoices/{id}/pay` to fire `payment.completed` + `invoice.paid` like the auto-pay cron path always did)
- **API version:** 2026-04-28.3
- **Document version:** 2026-04-29 (rev 16 — restructures cart docs: per-platform install walkthroughs at /docs/install/{widget,shopify,woocommerce,shop})
- **API version:** 2026-04-29
- **Document version:** 2026-04-30 (rev 17 — adds Messaging Billing & Caps: per-merchant tiers, monthly cap enforcement on `/v1/messages/sms` (402 `quota_exceeded`), 80%/100% spend alerts, monthly invoice rollover; new endpoints `/v1/messaging/usage`, `/v1/messaging/settings`, `/v1/admin/messaging/{tiers,usage,merchants/:id/messaging-tier}`)
- **API version:** 2026-04-30.2
- **Document version:** 2026-04-30 (rev 18 — adds ISV-scoped messaging fee schedule: per-ISV one-time fees (brand registration, campaign registration, TFV, number activation), per-ISV monthly recurring fees (per-number, per-campaign), per-ISV rate overrides on the platform tier, deferred fee-charge ledger swept by the day-1 rollover; new endpoints `/v1/admin/messaging/fees`, `/v1/messaging/fees`, `/v1/messaging/upcoming-charges`)
- **API version:** 2026-04-30.3
- **Document version:** 2026-04-30 (rev 19 — replaces per-rate overrides on `isv_messaging_fees` with a full ISV-scoped tier catalog `isv_messaging_tiers`. Each ISV defines their own tiers (e.g., Basic / Pro / Enterprise) with their own per-message rates and assigns merchants to one of them. `/v1/admin/messaging/tiers` is now ISV CRUD: GET / POST / PATCH /{id} / DELETE /{id}. Platform tier admin moves to `/v1/admin/messaging/platform-tiers`. Tier resolution: ISV catalog first, platform tier as backwards-compat fallback.)
- **API version:** 2026-04-30.4
- **Document version:** 2026-04-30 (rev 20 — replaces ISV tier catalog with **volume bands**. Each ISV defines one pricing schedule with stepped flat fees by monthly SMS volume (1–300 = $0, 301–500 = $5, etc.) plus a single SMS overage rate above the top band and a per-MMS rate. No per-merchant tier assignment — every merchant under an ISV pays the same schedule. Schema: `isv_messaging_volume_bands` replaces `isv_messaging_tiers`; `sms_overage_millicents` + `mms_millicents` added to `isv_messaging_fees`. The `messaging-tier` per-merchant assignment endpoint is replaced by `/v1/admin/messaging/merchants/{id}/caps` for raising individual cap ceilings.)
- **API version:** 2026-04-30.5
- **Document version:** 2026-04-30 (rev 21 — adds dual pricing modes per ISV. `isv_messaging_fees.pricing_mode` = `volume_bands` (auto-pick by monthly count) OR `subscription_tiers` (merchant picks upfront, billed flat every period). Same `isv_messaging_volume_bands` rows serve both modes. New `name` column on bands for plan labels. New `merchant_settings.messaging_tier_id` (FK) for the merchant's chosen tier. `PATCH /v1/messaging/settings { messaging_tier_id }` lets the merchant pick. `/v1/messaging/usage` response now includes `pricing.mode` + `pricing.selected_tier_id`.)
- **API version:** 2026-04-30.6
- **Document version:** 2026-04-30 (rev 22 — adds ISV-wide MMS toggle. `isv_messaging_fees.mms_enabled` (default true). When false, any `POST /v1/messages/sms` with non-empty `mediaUrls` is rejected with `403 mms_disabled`. Toggled from `/isv/messaging-billing`. Outbound MMS dispatch isn't implemented yet — `mediaUrls` returns `501 mms_not_implemented` even when allowed.)
- **API version:** 2026-04-30.7
- **Document version:** 2026-04-30 (rev 23 — adds an end-to-end 10DLC integration walkthrough at the top of section 4.10 for agent handoff. Reference docs (existing endpoints, schemas, status state machines) unchanged.)
- **API version:** 2026-04-30.7
- **Document version:** 2026-04-30 (rev 24 — adds two ISV-controlled billing-timing toggles. `setup_fee_billing_mode`: `next_invoice` (default) OR `immediate` — controls whether one-time setup fees roll into the monthly invoice or open a fresh invoice the moment they fire. `subscription_billing_mode` (subscription pricing only): `arrears` (default) OR `prepay` — `prepay` immediately invoices the merchant for the current period when they pick a tier. Subscription flat fees moved to the messaging_fee_charges ledger so prepay's UNIQUE-on-period_ym dedup catches the day-1 rollover and prevents double-charging.)
- **API version:** 2026-04-30.8
- **Document version:** 2026-04-30 (rev 25 — adds platform-level transactional emails for messaging-approval lifecycle events. Every brand / campaign / TFV transition to approved / failed / rejected fires a branded email to the merchant. Recipient resolution: brand.contactEmail / tfv.notificationEmail → fallback to merchant_settings.business_email. Two per-merchant toggles on merchant_settings: `notify_messaging_approvals` (default true) and `notify_messaging_failures` (default true), settable via `PATCH /v1/settings`. Failure emails include the carrier's failureReasons array verbatim with a retry link.)
- **API version:** 2026-04-30.9
- **Document version:** 2026-04-30 (rev 26 — adds a third notification trigger: `submitted`. Every successful brand / campaign / TFV submission to the carrier now fires a "We've submitted your registration" confirmation email with timeline expectations (1–8 weeks for 10DLC brands, hours for campaigns, 2–4 weeks for TFV). Third toggle on merchant_settings: `notify_messaging_submissions` (default true), settable via `PATCH /v1/settings`.)
- **API version:** 2026-04-30.10
- **Document version:** 2026-04-30 (rev 27 — fixes Sinch credential disagreement between `/v1/integrations/status` and `/v1/messaging/*`. The two paths checked DIFFERENT Sinch fields: integrations-status verified `sinch_api_token + sinch_service_plan_id` (SMS-batches API), messaging-provisioning required `sinch_api_token + sinch_project_id` (Numbers/10DLC API). Added a separate `sinch_provisioning` field to the `/v1/integrations/status` response that probes the project-scoped Numbers API, and improved the `no_messaging_provider_configured` error to name the specific missing fields.)
- **API version:** 2026-04-30.11
- **Document version:** 2026-04-30 (rev 28 — fixes the Sinch Numbers/10DLC API auth. The Service Plan token (`sinch_api_token`) used as Bearer worked for the SMS-batches API but was 401-rejected by the unified APIs. Sinch's Numbers / 10DLC / Voice surfaces require **Basic auth** with a separate Project access key. Added two new fields to `isv_settings`: `sinch_key_id` + `sinch_key_secret`. `service-messaging-sinch.ts` now uses Basic for those surfaces and Bearer (existing) only for the SMS surface. ISV must generate a Project access key at Sinch → Settings → Access keys and save both halves on `/isv/integrations`.)
- **API version:** 2026-04-30.12
- **Document version:** 2026-04-30 (rev 29 — adds Square OAuth Connect onboarding. New endpoints `POST /v1/onboarding/square/start` and `GET /v1/onboarding/square/callback`. ISV pastes their Square Developer App credentials (sandbox + production) and reseller referral URL into `/isv/integrations`. Embedders call `start` to get a popup-friendly URL — production routes through the reseller link for residual attribution; sandbox uses plain OAuth. Callback enforces new-account-only in production via a 24h `created_at` check. On success the OAuth tokens land in `merchant_providers.credentials` (or `test_credentials` for sandbox) with `provider='square'`. `merchant.provider.connected` webhook fires.)
- **API version:** 2026-04-30.13
- **Document version:** 2026-04-30 (rev 30 — completes Square integration: charge/void/refund via `/v2/payments`, location id auto-captured during onboarding, daily token refresh under the existing `tiyo-run-recurring-billings` cron, public webhook receiver `POST /v1/webhooks/square` (signature-verified, mounted pre-middleware) that mirrors `payment.*`/`refund.*`/`dispute.*` events into Tiyo's stream and flips the provider row inactive on `oauth.authorization.revoked`.)
- **API version:** 2026-04-30.13
- **Document version:** 2026-05-01 (rev 31 — adds platform-billing abstraction. Each ISV gets a "platform merchant" (`merchants.is_platform_merchant=true`) that bills operating merchants for SaaS subscriptions / setup fees / messaging. Operating merchants link to a customer record on the platform merchant via `merchants.platform_customer_id`. New endpoints: `PATCH /v1/admin/merchants/{id}/saas-billing` (upsert monthly recurring), `POST /saas-charge` (ad-hoc setup-fee invoice with auto-pay), `POST /saas-portal-link` (card-on-file URL). Messaging-billing's monthly rollover + immediate-flush invoices now write to the platform-customer with auto-pay enabled — one charge per merchant per period covering everything.)
- **API version:** 2026-05-01.1
- **Document version:** 2026-05-01 (rev 32 — adds configurable billing anchor on `customer_subscriptions`. `billing_anchor_mode` ('anniversary' default | 'calendar_day') + `billing_anchor_day` (1–28). `POST /v1/customer-subscriptions` accepts a `billing_anchor` body with `{ mode, day, first_cycle, grace_days }`. `first_cycle` controls the first-cycle policy in calendar_day mode: prorate (default — charge plan*daysRemaining/totalDays today, snap to anchor), advance (skip plan today, next cycle=anchor), or full (charge full plan today, snap to anchor). `PATCH /v1/customer-subscriptions/:id { billing_anchor: { mode, day } }` updates anchor for FUTURE cycles — current next_billing_date is preserved so mid-cycle flips don't double-bill. Solves the storage industry's "every tenant bills on the 1st" requirement.)
- **API version:** 2026-05-01.2
- **Document version:** 2026-05-01 (rev 33 — adds per-merchant default billing anchor. `merchant_settings.default_billing_anchor_mode` + `default_billing_anchor_day`. `POST /v1/customer-subscriptions` falls back to these when the request body omits `billing_anchor`. Set once on the merchant's `/settings` page; every subscription after that inherits the default unless explicitly overridden in the API call.)
- **API version:** 2026-05-01.3
- **Document version:** 2026-05-07 (rev 34 — large multi-feature push covering: tokenize fix (replaces $1 sale + auto-void with $0 Auth-Only / $1 auth + reversal fallback so chdTokens stop being revoked), `customer_cards.invalidated_at` + auto-pay skip + `payment_method.invalid` webhook, `POST /v1/admin/cards/revalidate` sweep + dashboard "Card health check" button, `POST /v1/admin/cards/test-mint{,-bulk}` for synthetic test-card seeding (test-mode merchants only — synthetic `BRZTEST_` tokens short-circuit the processor in service-payment.createPayment); subscription bundling primitive (`customer_subscriptions.bundle_key` + `invoice_items.subscription_id`, cron groups by `(customer, bundle_key, card, due_date)` and emits ONE `invoice.paid` with `metadata.subscription_ids[]`, `POST /v1/payments/{id}/refund { subscription_id }` for per-line refunds); Square parity (CNP authorize/capture/adjustTip + Card on File via `/v2/cards`, CP-mode terminal hardware via `/v2/terminals/checkouts`, terminal pairing routes `POST /v1/terminals/square/pair-code` + `GET /:id/status` + `POST /:id/repair`, hosted-pay form loads Square Web Payments SDK with Apple Pay + Google Pay buttons, Square added to standard Add-Provider dropdown, ISV-level `square_auto_board` flag returns `next_step.square_onboard_url` on `POST /v1/merchants/onboard`); manual batch close fans out `transactionType: 9` settlement across every active Dejavoo CNP TPN, with per-TPN results in the response; Sinch fixes (DNS host, `:rent`/`:release`/`:submit` colon-suffix paths, ISV-scoped `sinch_key_id`+`sinch_key_secret` Basic auth for unified APIs, `tcrBrand`-style nested body shape on brand registration with field-name remaps `entityType→brandEntityType` etc., `brandRegistrationStatus` overall-state read instead of TCR vetting state, `tcrBrandId` + `identityStatus` surfaced on brand response, campaign create at project-level `:submit` path with `brandId` (TCR id) reference + flat field shape, campaign `getCampaignStatus` polls `/feedback` endpoint when status indicates rejection and walks per-carrier `mnoMetadata` to populate `failureReasons[]`, `tcrCampaignId` + `lastActionStatus` surfaced on campaign response, `POST /v1/messaging/brands/{id}/sync` retry endpoint, brand `registrationTier` SIMPLIFIED ($10) vs FULL ($50, vetted) picker, `sinch_account_balance_insufficient` error code on funding shortfall); `GET /v1/messaging/tiers` merchant-scope tier picker; `GET /v1/messaging/failure-resolution[?code=]` failure-code dictionary that translates raw provider failure codes (TCR CR codes, Sinch operational, Twilio errors) to user-facing remediation copy with structured action steps; transactions page `pageSize` cap raised 100→500 with selector dropdown; Dejavoo sale uses fresh random `transactionReferenceId` per call (was deriving from `request.reference` which collided on subscription:sell:* under the 16-char slice).)
- **API version:** 2026-05-07.1
- **Edge function version:** see Supabase dashboard for current
- **Base URL:** `https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api`
- **OpenAPI 3.1 spec:** `GET /v1/openapi.json` (public, no auth)
- **This guide (always-latest):** `GET /v1/llm.md` (alias: `/v1/integration-guide.md`) — public, no auth, served as `text/markdown`. Pull this anytime to refresh; you don't need a dashboard login or even your API key. Example: `curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/llm.md`.
- **One-shot bundle (guide + OpenAPI in one JSON):** `GET /v1/llm.json` — public, no auth. Returns `{ generatedAt, guide: { format: "markdown", version, apiVersion, content }, openapi: {…} }`. Cache the `guide.version` string client-side and re-pull when it changes. Best entry point for code-generation LLMs that want everything in one request.
- **Live dashboard + docs:** https://tiyopay.vercel.app

### What changed since rev 12

- **Messaging Provisioning surface (new — see section 4.10).** Merchants can now self-serve phone-number purchase, A2P 10DLC brand + campaign registration, toll-free verification, and outbound voice through `/v1/messaging/*`. Twilio and Sinch live behind a single abstraction; the active provider per merchant comes from `merchant_settings.defaultMessagingProvider` (override per request via the `provider` body field where supported).
- **`POST /v1/messages/sms` is now approval-gated.** When `merchant_settings.enforceMessagingApproval=true` (default), a long-code `from` number must be attached to an APPROVED campaign and a toll-free `from` must have an APPROVED toll-free verification — otherwise the route returns `409 messaging_not_approved` / `409 tollfree_not_verified` with the offending status in the body. Set `enforceMessagingApproval=false` on settings (admin only) to bypass for one-off testing. The route also now accepts an optional `from` field — `brz_msgnum_...` external id OR raw e164 — to pin the originating number; when omitted the gateway picks the first active SMS-capable number with an approved sender path.
- **New webhook event types (see section 8.4):** `messaging.brand.{submitted,approved,failed}`, `messaging.campaign.{submitted,approved,failed,numbers_attached,number_detached}`, `messaging.tfv.{submitted,approved,rejected}`, `messaging.number.{purchased,released}`, `messaging.sms.sent`.
- **SDK additions (see section 12).** `TiyoClient.messaging` exposes namespaced sub-resources: `.numbers.{search,buy,list,get,update,release}`, `.brands.{create,list,get,update,refreshStatus}`, `.campaigns.{create,list,get,update,refreshStatus,attachNumbers,detachNumber}`, `.tollfreeVerifications.{create,list,get,update,refreshStatus}`, `.voice.call(...)`, `.sms.send(...)`. All return strongly-typed payloads.
A four-piece website-cart stack landed in this batch. Each piece
is independently shippable; a merchant can pick any one (or all
four together).

- **Data foundation.** New column `merchant_settings.ach_discount_percent` (basis points) for the no-surcharge dual-pricing program — distinct from `cash_discount_percent` so a merchant can offer different tiers for cash vs ACH. New columns on `checkout_sessions`: `line_items` (jsonb itemized cart), `payment_methods` (jsonb subset of `[card, ach, cash]`), `pricing` (jsonb pre-computed dual-pricing snapshot), `metadata` (jsonb caller tags). `POST /v1/checkout/sessions` accepts and persists all four. `GET /v1/checkout/sessions/:id` returns them.
- **Public no-auth session mint.** `POST /api/pay/sessions` (on the dashboard host, not the gateway path) accepts a `merchantId`-only payload and proxies through internal credentials to mint a checkout session. Stripe-style "publishable identity" — lets a website widget run without the merchant putting their secret key in the page HTML. Whitelists forwarded fields so the public surface can't smuggle dashboard-only knobs.
- **Drop-in `<script>` widget.** `https://<dashboard>/tiyo-cart.js` — vanilla JS, no dependencies. Two-line integration: `<script src=… data-merchant=brz_mer_…></script>` plus `<button data-tiyo-pay data-amount=… data-line-items=…>Pay</button>`. Auto-renders dual pricing on `<span data-tiyo-price data-base=…>` tags. Programmatic API at `window.Tiyo.{checkout, refreshPrices, computePricing, getConfig}`. Supports redirect / popup / iframe modes. Demo at `/cart-demo.html`.
- **WooCommerce plugin.** `integrations/woocommerce/tiyo-payments/` — PHP plugin extending `WC_Payment_Gateway`. Settings panel for merchant id and payment-method gating, `process_payment` builds line items in cents and POSTs to `/api/pay/sessions`, redirects to the hosted page. Webhook handler at `?wc-api=tiyo_payment_webhook` matches orders by `metadata.wc_order_id` (round-tripped via the new metadata field) or by stamped session id, calls `$order->payment_complete($txn_id)`. Handles `payment.refunded` / `payment.failed` too.
- **Shopify drop-in.** `dashboard/public/tiyo-shopify.js` + `tiyo-shopify-snippet.liquid`. One-line theme snippet pasted into `cart.liquid`; reads Shopify's live cart via the public `/cart.js` Ajax API, mints a Tiyo checkout session via `/api/pay/sessions` (same endpoint the widget + Woo plugin use), redirects. Stamps `transactions.reference = shopify-cart:<token>` and the full Shopify cart on `transactions.metadata` so reconciliation back to the Shopify order is a token search. Lightweight only — Shopify still owns the cart and the order, merchant marks Shopify orders paid manually after Tiyo receipt arrives. Full payment-method-in-checkout integration would require building a Shopify Payments App (a multi-week Partner-account / app-review project, deliberately out of scope for V1).
- **Webhook envelope enrichments.** `payment.completed` (and `.declined` / `.failed`) events now include the transaction's `reference`, `metadata`, and a top-level `session_id` (when the charge ran against a checkout session). The dashboard `/api/pay/charge` route fetches the session's metadata at sale time and stamps it on the transaction so the WC plugin (and any other consumer) can match the event back to its own order ids without a follow-up GET.
- **Tiyo-native hosted storefront.** New page at `/shop/{merchantId}` — public, no dashboard login. Renders a product gallery hydrated from a new public `GET /api/shop/products?merchantId=…` (wraps `/v1/products` behind X-Merchant-Id, filters inactive). Cart drawer with localStorage persistence keyed by merchant. Checkout calls `/api/pay/sessions` with `metadata.source="tiyo_shop"`. Targets merchants who don't have their own website. No new gateway tables — cart lives in the browser; promote to a server-side `/v1/carts` resource later if abandonment tracking is needed. Hosted page exempt from the dashboard auth redirect (same as `/pay/`, `/portal/`, `/r/`).

### What changed since rev 11

- **`POST /v1/terminals/{id}/pair-code` (new).** Mints a fresh 8-digit pair code (same `XXXX-XXXX` format as `/v1/terminals/pair-codes`) BOUND to an existing terminal. When the new device redeems the code via `/v1/terminals/pair`, the gateway rotates the existing terminal's device_secret instead of creating a new terminal row. Preserves the terminal's `external_id`, `dejavoo_tpn` pin, config, and every historical `transactions.terminal_id` reference. Old device's secret stops working only when the new device actually pairs — there's no gap during installer travel time.
- **`/v1/terminals/pair` now branches on `target_terminal_id`.** Existing first-time pair flow is unchanged: redeem a code → create a new terminal row + return its `device_secret`. New re-pair flow: the redeemed code carries `target_terminal_id` → update that row's `device_secret_hash`, clear `last_seen_at`, restore `status='active'`, optionally update the terminal's `name` if the device sent one. Same response shape either way (terminal + `device_secret`).
- **Issuing a fresh re-pair code invalidates the prior unused one.** Each call to `/v1/terminals/{id}/pair-code` marks any earlier unused re-pair code for the same terminal as `usedAt=NOW()`, so exactly one re-pair code is live per terminal at a time. Two concurrent rotations can't race.
- **Dashboard ships a "Re-issue pair code" button** on each terminal row in Merchants → \<merchant\> → Integrate. Surfaces the same large pair-code modal that already shows for new pairings, with the same 10-minute default expiry.

### What changed since rev 10

- **`POST /v1/developers/{id}/keys/{keyId}/rotate` (new).** Mints a fresh secret on the SAME API-key row. The public `external_id` is preserved (so any downstream config that references the key by id keeps working) and any outstanding refresh tokens issued under the old secret are revoked. Reactivates the key if it was previously revoked. Returns the new secret once — same response shape as the create endpoint. Use case: a Tiyo POS terminal install moves to a different physical device, or an SDK-consumer ops team needs to roll the secret without losing the key's name/scopes/audit-log lineage.
- **`POST /v1/terminals/{id}/rotate` (new).** Same idea for terminal device secrets. Issues a fresh `brz_term_sec_…` on the same terminal row, clears `last_seen_at`, restores `status='active'` if previously revoked. Old device's secret stops working immediately because its sha256 no longer matches `device_secret_hash`. Returns the new secret once.

### What changed since rev 9

- **Saved cards dedup by physical-card fingerprint, not just reusable token.** The auto-save-on-approved-sale path AND the manual `POST /v1/customers/{id}/cards` route now go through one shared upsert: match on token → match on `(brand, last4, exp_month, exp_year)` → insert. When the fingerprint matches but the token is fresh (Dejavoo iPOSpays and EPX both mint fresh tokens on re-tokenization of the same plastic), we update the existing row's token instead of inserting a duplicate. The DB enforces this with a unique index `customer_cards_fingerprint_unique` so concurrent inserts can't sneak through.
  - **Behavior change for `POST /v1/customers/{id}/cards`:** the response status is now `200` instead of `201` when the request matches an existing card on file (either by token or fingerprint). The body is identical — `formatCard(row)` either way — but consumers can branch on the status if they want to show "card already saved" vs "new card added."
- **`POST /v1/me/branding/extract` now reads CSS as primary signal.** For sites whose logo is monochrome but whose marketing page declares brand colors in CSS — buttons, headings, link styles — the extractor now parses every `color:` and `background-color:` declaration out of the page HTML, drops neutrals, buckets by 30°-hue, and returns the most-voted hue's mid-saturation representative. Image-based extraction (logo dominant color → favicon dominant color) stays as a fallback for sites with no inline color declarations. Massively more accurate for storage / utility brand sites that ship a black-on-white wordmark.
- **`POST /v1/logos/from-url` accepts an optional `primaryColor`.** If the caller already determined the brand color (typically from a prior `/v1/me/branding/extract` call), pass it as `primaryColor` and `from-url` will write it across all three brand fields (`brand_color`, `accent_color`, `invoice_brand_color`) without re-running image extraction. Response includes `colorSource: "caller"` so the consumer can tell where the color came from.

### What changed since rev 8

- **`/v1/logos/from-url` now returns brand color reliably.** After the upload completes, the route runs dominant-color extraction against a downsized copy of the merchant's rehosted logo (via the public weserv.nl image proxy on the now-public bucket URL, with the Supabase image-transform endpoint as fallback). The result is returned as `primaryColor` and also written to `merchant_settings.brand_color`. Why a transform path: the original logo is often a 7000+ px asset that would OOM-decode on the function; a 512×512 downsample decodes in milliseconds.
- **`colorMonochrome` flag (new).** When the merchant's logo has no detectable brand color (every sampled pixel is near-white, near-black, or low-saturation gray — common for storage / utility brands that use plain black-on-white wordmarks), the response carries `colorMonochrome: true` AND `primaryColor: null`. The existing `merchant_settings.brand_color` is cleared on this path so the dashboard surfaces a "pick a color" affordance instead of leaving a stale wrong color from a prior extraction. The merchant can override at any time via `PATCH /v1/settings`.
- **Why this matters.** Pre-fixing extraction was reading the favicon as a fallback when the actual logo was too large to decode. For storage-software-platform merchants (storEDGE, SiteLink, etc.) the favicon is the *platform's* default storage-icon, not the merchant's brand. The fix routes around that entirely: rehost first, extract from the rehosted copy via a CDN-resize, fail loudly when there's nothing to extract instead of silently picking the platform default.

### What changed since rev 7

- **`primaryColor` accuracy.** The previous extractor picked `<meta name="theme-color">` / `<meta name="msapplication-TileColor">` as the primary signal — but platform-built sites (storEDGE, SiteLink, etc.) routinely stub those with the platform's default (`#ffffff`, `#2d89ef` Microsoft tile blue, etc.) rather than the merchant's actual brand. The extractor now does image-based extraction first (logo dominant color, then largest-declared favicon dominant color, sorted by declared size), and only falls back to meta-tag colors as a last resort. Neutral colors (near-white, near-black, low-saturation) at the meta layer are also discarded.
- **No more 546 OOM crashes on giant logos.** Extracting dominant color from a 7200×4200 logo was decoding to a ~120 MB raw bitmap and OOM-killing the Edge Function (status 546 / silent null result). Added cheap header-only dimension peek (JPEG SOF, PNG IHDR, GIF, WebP VP8/VP8L/VP8X) so we refuse to feed >4 MP images to imagescript. Source bytes also capped at 5 MiB. When the logo is too large the favicon fallback (smaller, decodes safely) takes over.
- **HTML entities are decoded *before* the image fetcher runs.** Pre-signed S3 URLs come with `&amp;` between query params; encoded ampersands break the fetch. Previously this only mattered for the returned `logoUrl`; now it also affects the in-extractor color-extraction fetch.
- **Meta-color regex matches both attribute orders.** `<meta content="..." name="theme-color">` (content-first) was being missed entirely; only `<meta name="theme-color" content="...">` matched.
- **Logo wrapper detection.** Recognized `<div class="logo"><img …></div>` patterns (the img has no class itself), checked `<img alt="logo">` as an additional hint, and decoded HTML entities in the returned URL.

### What changed since rev 6

- **`POST /v1/logos/from-url` (new).** Companion to `POST /v1/logos` for the case where you already have a logo URL but can't trust it long-term. Body: `{ url, target?: "logo_url" | "invoice_logo_url" | "both" | "none" }`. Tiyo fetches the bytes server-side, rehosts them in the `brand-logos` bucket, and (optionally) writes the resulting permanent URL into merchant_settings. **Critical for storage-software platforms** (storEDGE, SiteLink, etc.) that serve their merchant logos as pre-signed S3 URLs that expire in minutes — pointing `invoice_logo_url` at one of those would 404 the next time a receipt rendered. Pair with `/v1/me/branding/extract`: extract returns `logoUrl`, this endpoint persists it.
- **Extractor accuracy fixes.** `POST /v1/me/branding/extract` now (a) follows HTTP redirects manually because Deno's edge fetch was returning the 65-byte "Moved Permanently" body verbatim instead of chasing them — most platform sites 301 from apex to www; (b) automatically retries with `www.host` when the apex returns nothing useful; (c) detects logos inside wrapper elements (`<div class="logo"><img …></div>`) in addition to images that have the logo class on themselves; (d) checks `<img alt="logo">` as a third hint; (e) decodes `&amp;` and other HTML entities in the returned URL so it's directly fetchable.

### What changed since rev 5

- **`POST /v1/logos` (new).** Multipart binary upload for merchant logos. API consumers used to be stuck pointing `merchant_settings.logo_url` at a URL they hosted themselves — they couldn't push bytes through Tiyo. This endpoint accepts the file directly, stores it in the `brand-logos` Supabase Storage bucket under a per-merchant path, and (optionally) writes the resulting public URL into `merchant_settings.logo_url` and/or `merchant_settings.invoice_logo_url` via the `target` form field. PNG / JPEG / GIF / WebP / SVG, max 5 MiB. Returns `{ url, path, appliedTo, contentType, size }`.
- **`POST /v1/me/branding/extract` (existing — now documented).** Pass `{ url: "acme.com" }` and the gateway scrapes the site's `<meta>` tags + favicon to return `{ name?, logoUrl?, primaryColor? }`. Use as a one-call autofill before showing your branding form to the merchant. Doesn't write — chain it into `POST /v1/logos` (with the returned `logoUrl` mirrored to your bucket) or just `PATCH /v1/settings` to persist.
- **`/v1/settings` already supported logo URLs** via `logo_url` and `invoice_logo_url`. The two new endpoints sit on top: `/v1/logos` lets you skip "host the file yourself"; `/v1/me/branding/extract` lets you skip "ask the merchant to upload anything at all" when they already have a public website.

### What changed since rev 4

If your code already works against rev 4 these are *behaviour-preserving* fixes — no breaking schema or shape changes — but they materially improve correctness for any flow that voids or refunds a transaction.

- **Voids and refunds are now strictly host-side.** The terminal is contacted exactly once: on the original card-present sale that mints the token / RRN. Every subsequent operation (`POST /v1/payments/{id}/void`, `POST /v1/payments/{id}/refund`, repeat charges, recurring billing) goes through the merchant's CNP provider row (Dejavoo iPOSpays TransactAPI by RRN, EPX by transaction id, etc.) regardless of whether the original sale was hosted-page or card-present. **The terminal is never re-contacted after the initial token mint.** The previous router could fall back to an unrelated SPIn terminal when voiding a CNP-pinned sale, producing spurious `Terminal not connected to SPIn Proxy server` errors on transactions that never involved a terminal — that fallback is gone.
- **Failed voids no longer flip the original sale.** If the processor declines or errors on the void, the original charge stays `approved` (not `failed`). The void error is preserved under `provider_response.lastVoidAttempt` and the response shape carries `voidStatus: "failed"` plus `responseCode` / `responseMessage`. Previously a transient void error was clobbering the original sale's status and provider_response, making real charges look failed in the dashboard.
- **`POST /v1/payments/{id}/void` response shape change (additive):** on a failed void you now get `voidStatus: "failed"`, `responseCode`, `responseMessage` alongside the unchanged `id` / `status` / `amount` / `type` / `reference` / `created_at`. The `status` field still reflects the *transaction's* state (`approved` if the void didn't land), not the void attempt's state. Branch on `voidStatus === "failed"` for retry / surface-the-error logic.
- **Tokenize cleanup hardened.** `POST /v1/payments/tokenize` now waits 2s after the $1 setup-intent sale before firing its auto-void, and the SPIn provider retries transient `Terminal not connected` / `Terminal in use` errors automatically (only relevant when the tokenize itself was card-present). Hosted-page tokenize voids are pure host-side TransactAPI calls and don't need the retry. If the auto-void still doesn't land for any reason, the $1 auth expires unfunded after ~7 days — no money moves.

---

## 1. Mental model

Tiyo Pay is a multi-tenant payment gateway + POS platform. Key concepts:

- **Merchant** — a business that accepts payments. Every resource is scoped to
  a merchant. Merchants can be in **test_mode** (sandbox credentials) or live.
- **Developer** — a user with one of three roles:
  - `admin` — platform staff; sees every merchant.
  - `developer` — ISV / reseller; sees merchants they own (direct grants or
    via merchant groups).
  - `merchant` — merchant staff; sees their own merchant + any chain/group
    siblings.
- **Merchant Group** — a chain or portfolio (e.g. "Acme Coffee Shops"). Grants
  access to every merchant in the group with one junction row.
- **API Key** — merchant-scoped long-lived credential. Two halves: `client_id`
  (`brz_key_...`) and `client_secret` (`brz_sec_...`). Exchange for a JWT at
  `/v1/auth/token`.
- **Providers** — each merchant is wired to one or more payment processors.
  Card-not-present: EPX North, Paya (ACH), Vericheck (ACH), Dejavoo iPOSpays,
  Linked2Pay. Card-present: Dejavoo SPIn (physical terminal). The gateway
  routes your call to the right provider transparently.

All resource IDs are prefixed strings: `brz_cus_`, `brz_prd_`, `brz_txn_`,
`brz_inv_`, `brz_plan_`, `brz_sub_`, `brz_rec_`, `brz_whk_`, `brz_chk_`,
`brz_mer_`, `brz_dev_`, `brz_key_`, `brz_bat_`, `brz_mgrp_`, `brz_event_`.

---

## 2. Conventions

- All **amounts are integer cents** (USD). `$12.34` = `1234`. Never floats.
- All **timestamps are ISO 8601** with trailing `Z` (UTC).
- All **list endpoints** return `{ data: [...], total, page, pageSize }` or
  `{ data: [...], has_more, next_cursor }` for cursor-paginated endpoints.
- **snake_case** in request/response bodies. `customer_id`, `due_date`,
  `card_price`, `next_billing_date`.
- All **POST/PATCH** accept JSON (`Content-Type: application/json`).
- **Authentication:** `Authorization: Bearer <token>` on every `/v1/*`
  endpoint except `/v1/auth/token` and `/v1/openapi.json`. The token is
  either the raw API key (`brz_secret_...`) or a 15-min JWT exchanged
  via `POST /v1/auth/token`. See §3.
- **Merchant context** for admins/ISVs: pass `X-Merchant-Id: brz_mer_...` to
  scope a request to a specific merchant they have access to. If omitted,
  the API uses the caller's primary merchant. If the override names a
  merchant the caller cannot access, the API falls back to the primary —
  never silently returns empty lists.
- **Idempotency:** pass `Idempotency-Key: <any-string-≤255-chars>` on any
  POST to safely replay within 24 h. REQUIRED on `POST /v1/payments`.
- **API versioning:** pin a specific API version with `Tiyo-Api-Version:
  2026-04-21`. Omitting uses your account's default version.
- **Request IDs:** every response carries `Tiyo-Request-Id: req_...`. Save
  it for support tickets — logs are kept server-side 7 days.

### Error envelope

Every 4xx/5xx response looks like:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_error",
    "message": "Human-readable description.",
    "request_id": "req_abc123...",
    "details": { }
  }
}
```

`type` values: `invalid_request_error`, `authentication_error`,
`authorization_error`, `not_found`, `conflict_error`, `validation_error`,
`rate_limit_exceeded`, `api_error`.

### Rate limits

200 req / 10 s per API key (300 burst). Every response echoes
`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`. Over the burst
cap → `429` with `Retry-After`.

---

## 3. Authentication

> **Important — read first.** For a normal single-merchant integration, the
> ONLY credentials you need are the API key + secret. **You do NOT need a
> separate merchant_id, account_id, or business_id.** The merchant context is
> baked into the API key — the gateway resolves which merchant the call is
> for from the key alone. If your code-generation LLM is asking the user for
> a "Tiyo merchant ID" alongside the API credentials, that's wrong: the API
> key + secret are sufficient.
>
> A merchant_id (`X-Merchant-Id` header, value like `brz_mer_...`) is only
> required in the **admin or ISV** multi-merchant case (see section 11). A
> single merchant integrating their own store ignores merchant_id entirely.

Two equivalent paths — both work in production, pick whichever fits your client:

1. **Direct API-key Bearer** (simplest). Send the raw API key as the bearer
   token on every request: `Authorization: Bearer brz_secret_...`. The gateway
   hashes it and looks up the matching `api_keys` row. No exchange step,
   no expiry to manage.

2. **OAuth client_credentials → 15-min JWT** (good when your HTTP client
   expects OAuth shape, or when you want short-lived tokens). Exchange the
   API key for a JWT once, cache it, refresh before it expires.

`client_id` is the API key's `external_id` (e.g. `brz_key_8a8e61d3...`),
`client_secret` is the raw key you'd otherwise use as Bearer (e.g.
`brz_secret_6fec93...`). Both must come from the **same** API key row —
mixing the external_id of one key with the secret of another is the most
common cause of `401 invalid_client`.

**Credentials checklist for a single-merchant integration:**

| Required | Field | Format | Where to get it |
|---|---|---|---|
| ✅ | API key (`client_id`) | `brz_key_...` | Tiyo dashboard → Settings → API Keys |
| ✅ | API secret (`client_secret`) | `brz_secret_...` | Same row as the API key, shown once at creation |
| ❌ | merchant_id | — | Not used for single-merchant integrations. The merchant is implied by the key. |
| ❌ | business_id / account_id / org_id | — | None of these exist in Tiyo. If a code-gen LLM invents one, ignore it. |

### 3.1 Exchange API key for a JWT

```http
POST /v1/auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=brz_key_...&client_secret=brz_secret_...
```

Response:

```json
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "hex32..."
}
```

The JWT lives 15 minutes. Refresh with:

```http
POST /v1/auth/token
grant_type=refresh_token&refresh_token=<hex32>
```

A refresh also rotates the refresh_token — the old one is revoked.

### 3.2 Revoke a token

```http
POST /v1/auth/revoke
{ "token": "<jwt>" }            # revoke an access token
{ "refresh_token": "<hex32>" }  # revoke a refresh token
```

### 3.3 The caller's identity

```http
GET /v1/me
```

Returns the authenticated developer + active merchant + role + branding:

```json
{
  "developerId": "brz_dev_...",
  "email": "...",
  "role": "developer",
  "activeMerchantId": "brz_mer_...",
  "activeMerchantName": "...",
  "accessibleMerchantCount": 5,
  "branding": { "name": null, "logoUrl": null, "primaryColor": null }
}
```

```http
GET /v1/me/accessible-merchants
```

Returns the list of merchants the caller can scope requests to:

```json
[
  { "id": "brz_mer_...", "name": "Main Store", "status": "active", "testMode": false },
  ...
]
```

---

## 4. Core flows

### 4.1 Charge a card — card-not-present (hosted form)

The safest and simplest integration. You don't touch PAN.

**Step 1:** mint a checkout session.

```http
POST /v1/checkout/sessions
Idempotency-Key: order-1234
{
  "amount": 5000,
  "reference": "invoice-42",
  "return_url": "https://yoursite.com/thanks",
  "customer_id": "brz_cus_...",   // optional
  "save_card": true                // optional
}
```

Response:

```json
{
  "id": "brz_chk_...",
  "url": "https://tiyopay.vercel.app/pay/brz_mer_...?session=brz_chk_...",
  "amount": 5000,
  "status": "pending",
  "expires_at": "..."
}
```

**Step 2:** redirect the customer to `url`. They fill the hosted card form.

**Step 3:** listen for `payment.completed` webhook OR poll
`GET /v1/checkout/sessions/{id}`. `save_card: true` + `customer_id` means
the reusable token is automatically attached to the customer on success —
available immediately at `GET /v1/customers/{id}/cards`.

### 4.2 Charge a saved card (card-on-file)

```http
POST /v1/payments
Idempotency-Key: sub-cycle-2026-04-23
{
  "type": "card",
  "amount": 5000,
  "cardId": 42,             // integer id from GET /v1/customers/{id}/cards
  "customerId": "brz_cus_...",
  "reference": "monthly-2026-04"
}
```

### 4.3 Charge a card — card-present (in-store terminal)

Routes to the merchant's Dejavoo SPIn terminal. The server holds the HTTP
connection open (up to ~3 min) while the customer taps/inserts at the
terminal, then returns the final result.

```http
POST /v1/payments
Idempotency-Key: pos-sale-0001
{
  "type": "card",
  "mode": "cp",              // card-present
  "amount": 5000,
  "customerId": "brz_cus_..."      // optional — attaches the charge to a customer
  // Optional: pin to a specific physical terminal for merchants with
  // more than one countertop (Front Counter, Drive-Thru, etc.). Either
  // form works:
  //   "physical_terminal_id": 42
  //   "dejavoo_tpn": "TPN111"
}
```

**Picking a terminal when the merchant has several:**
`GET /v1/terminal/bootstrap` returns `paymentProviders.cardCp` — one row
per physical Dejavoo device, each with `{ id, name, dejavooTpn }`. The
POS renders them as a selector; when a sale happens, pass the row's
`id` as `physical_terminal_id`.

**Default routing (no pin):** if the caller authed with a paired POS
terminal secret (`brz_term_sec_…`) and that POS row's
`terminals.dejavoo_tpn` is set, sales auto-route there. Otherwise the
first active Dejavoo CP row for the merchant wins.

Response status values for `cp`:
- `approved` — cardholder completed the sale.
- `declined` — issuer declined.
- `failed` — gateway/processor error.
- `canceled` — customer hit Cancel at the terminal. NO charge was made.

### 4.3a Void or refund a charge

```http
POST /v1/payments/{id}/void
POST /v1/payments/{id}/refund    { "amount": 500 }   # optional; full refund if omitted
```

**Routing rule (important):** voids and refunds are always host-side. The terminal is touched exactly once — on the original card-present sale that mints the token. After that, *every* operation (void, refund, repeat-charge, recurring billing) goes through the merchant's CNP provider row by RRN. This is true even when the sale was card-present: the terminal is never re-contacted. You don't need to keep the terminal online to void a CP sale.

**Response on success:**

```json
{
  "id": "brz_txn_...",
  "status": "voided",            // or "refunded" / "partial_refunded"
  "amount": 100,
  "type": "card",
  "reference": "...",
  "created_at": "2026-04-27T16:51:09.900Z"
}
```

**Response on a failed void (200 OK, status preserved):**

```json
{
  "id": "brz_txn_...",
  "status": "approved",          // unchanged — the sale still settled
  "amount": 100,
  "type": "card",
  "reference": "...",
  "created_at": "2026-04-27T16:51:09.900Z",
  "voidStatus": "failed",
  "responseCode": "...",
  "responseMessage": "..."
}
```

A failed void never flips the original sale to `failed`. Branch on `voidStatus === "failed"` to retry or surface the processor message; the underlying charge is still good. The void error is also persisted under `provider_response.lastVoidAttempt` for forensics.

### 4.3b Brand the merchant — logo + colors

Three ways to wire up branding, pick whichever fits your software:

**(a) Auto-detect from the merchant's website (zero-input UX).** Have them type their website URL once; the gateway scrapes meta tags + favicon and gives you back name/logo/primary-color to pre-fill.

```http
POST /v1/me/branding/extract
{ "url": "acme.com" }
```

```json
{
  "name": "Acme Storage",
  "logoUrl": "https://acme.com/static/logo.png",
  "primaryColor": "#0F62FE"
}
```

`logoUrl` and `primaryColor` may be `null` if the site doesn't expose them. The endpoint *only fetches* — nothing is persisted. Pipe the result into `POST /v1/logos` (rehosting the bytes on Tiyo so the merchant's site outage doesn't break receipts) or directly into `PATCH /v1/settings` if you want to keep pointing at their hosted asset.

**Recommended chain for "auto-detect from website" (e.g. storage-software-built sites):**

```http
# 1. Extract — returns a logo URL that may be a short-lived pre-signed S3 URL
POST /v1/me/branding/extract
{ "url": "merchant-website.com" }

# 2. Re-host it on Tiyo so the URL is permanent
POST /v1/logos/from-url
{ "url": "<logoUrl from step 1>", "target": "invoice_logo_url" }
```

This is mandatory for storage-software platforms (storEDGE, SiteLink, etc.) — their site logos are served from S3 with `X-Amz-Expires` query params measured in minutes. Pointing `invoice_logo_url` directly at an extracted URL means receipts 404 a few minutes later.

**(b) Upload bytes directly (your software has the logo file).**

```http
POST /v1/logos
Authorization: Bearer <jwt>
X-Merchant-Id: brz_mer_...
Content-Type: multipart/form-data

file=<bytes>
target=invoice_logo_url
```

```json
{
  "url": "https://crwbtzryadlpmlupgcmz.supabase.co/storage/v1/object/public/brand-logos/merchant-4/logo-….png",
  "path": "merchant-4/logo-….png",
  "appliedTo": ["invoice_logo_url"],
  "contentType": "image/png",
  "size": 18432
}
```

`target` accepts: `logo_url` (hosted-pay page + portal), `invoice_logo_url` (invoices + receipts), `both`, or `none` (just upload, don't apply). Allowed types: PNG, JPEG, GIF, WebP, SVG. Max 5 MiB. Returns the public URL — store it on your side too if you need it.

**(c) Point at a URL you already host.** Plain `PATCH /v1/settings`:

```http
PATCH /v1/settings
{ "logo_url": "https://your-cdn/logo.png", "invoice_logo_url": "https://your-cdn/invoice-logo.png" }
```

Tiyo renders from your URL at request time. Outage on your end ⇒ logo doesn't render. Use (b) for resilience.

### 4.4 Save a card without charging (Setup Intent)

```http
POST /v1/setup-intents
{ "customer_id": "brz_cus_...", "return_url": "..." }
```

Returns a hosted URL. Customer tokenizes their card; no charge lands on
their statement. Saved card appears under
`GET /v1/customers/{id}/cards` on completion.

### 4.5 Send an invoice

```http
POST /v1/invoices
{
  "customer_id": "brz_cus_...",
  "due_date": "2026-05-15",
  "auto_pay": true,         // cron charges the default card on due_date
  "allow_partial": false,
  "items": [
    { "name": "Consulting — April", "quantity": 1, "unit_price": 10000 },
    { "name": "Expedited delivery", "quantity": 2, "unit_price": 1000 }
  ]
}
```

Then:

```http
POST /v1/invoices/{id}/send
{ "channels": { "email": true, "sms": false } }
```

We render a branded email (with a Pay button linking to the hosted form),
fire `invoice.sent`, and mark the invoice `sent`. When it settles (via the
hosted page OR auto-pay cron OR manual `POST /v1/invoices/{id}/pay`) we
fire **both** `payment.completed` (with `data.metadata.invoiceId` set so
you can correlate the charge back to the invoice) **and** `invoice.paid`
(with `data.transaction_id` set so you can correlate the invoice back to
the charge). Same envelope shape regardless of which path settled the
invoice — your consumer doesn't need to distinguish manual-pay vs
auto-pay vs hosted-pay.

### 4.6 Subscribe a customer to a plan

Create the plan once:

```http
POST /v1/membership-plans
{
  "name": "Monthly Parking",
  "price": 29500,
  "billing_cycle": "monthly"   // weekly|biweekly|monthly|quarterly|annual
}
```

Then attach a customer:

```http
POST /v1/customer-subscriptions
Idempotency-Key: sell-plan-jane-2026-04-23
{
  "customer_id": "brz_cus_...",
  "plan_id": "brz_plan_...",
  "card_id": 42,               // OR payment_token for a new card
  "charge_now": true,          // default: charge the first cycle immediately
  "items": [                   // OPTIONAL one-time add-ons on the first charge
    { "name": "Enrollment fee", "quantity": 1, "unit_price": 2500 }
  ]
}
```

Our cron handles every subsequent cycle. Failures retry in the same window
(1 h, 4 h, 1 d, 3 d); after 3 consecutive failures the subscription moves
to `past_due` and fires `subscription.payment_failed`.

### 4.7 DIY recurring (you own the cron)

If your ERP already drives the schedule, skip subscriptions and call
`POST /v1/payments` with `cardId` each cycle. No subscription row is
created — every charge stands alone.

### 4.8 Customer portal (self-serve)

Mint a signed URL; hand it to your customer:

```http
POST /v1/customers/{id}/portal-sessions
{ "return_url": "https://yoursite.com/account", "expires_in": 3600 }
```

Returns `{ id, url, token, expires_at }`. They land on a page where they
can cancel subscriptions, add / replace cards, view invoices.

### 4.9 Send an arbitrary email or SMS

For free-form messages (rent reminders, appointment confirmations, ad-hoc
customer notes) — anything that isn't a Tiyo receipt, invoice, or portal
link — call the messaging endpoints directly. They route through the
merchant's resolved Resend / Twilio / Sinch creds, honor the ISV-scope
flags, and gate on each merchant's `integration_*_enabled` toggle.

```http
POST /v1/messages/email
Authorization: Bearer brz_secret_...
{
  "to": "tenant@example.com",
  "subject": "Storage rent due in 3 days",
  "html": "<p>Hi Jane, your unit B-12 rent of $129 is due Friday.</p>",
  "text": "Hi Jane, your unit B-12 rent of $129 is due Friday.",
  "reply_to": "billing@yourstoragefacility.com"
}
```

```http
POST /v1/messages/sms
Authorization: Bearer brz_secret_...
{
  "to": "+15551234567",
  "body": "Hi Jane, your B-12 rent of $129 is due Friday. Reply STOP to opt out."
}
```

Both return `{ success, channel, provider, id }` on success; the `id` is
the upstream provider's message id (Resend/Twilio/Sinch) for cross-system
lookup. 400 if the integration is disabled for this merchant; 500 if the
upstream provider rejects the send.

For transactional sends — receipts and invoices — use the resource-bound
endpoints instead (`POST /v1/payments/{id}/receipt`,
`POST /v1/invoices/{id}/send`). They render the right HTML and include
the hosted-receipt or pay link automatically.

### 4.10 Messaging Provisioning (phone numbers, A2P 10DLC, toll-free, voice)

The endpoints in `/v1/messages/*` (above) get the SMS / email out the
door once the channel is set up. Setting up the channel — buying a
number, registering an A2P 10DLC brand and campaign, or submitting a
toll-free verification — is what `/v1/messaging/*` is for. Every
resource is per-merchant; the ISV's Twilio / Sinch credentials act as
the Communications Service Provider behind the scenes.

#### Zero-to-sending walkthrough (handoff for dashboard agents)

If you're building the dashboard UI for setting up SMS on a merchant
account, this is the canonical sequence. Every step is a single API
call against the gateway; the ISV's Twilio / Sinch credentials are
already on `isv_settings` (no per-merchant cred entry needed).

**1. Confirm the ISV has live messaging credentials.**
```http
GET /v1/integrations/status
```
Returns a per-provider live auth check. The response includes BOTH
`sinch` (SMS-batches API — needs `sinch_api_token` +
`sinch_service_plan_id`) and `sinch_provisioning` (Numbers/10DLC API
— additionally needs `sinch_project_id`). For this walkthrough you
need `twilio.ok = true` OR `sinch_provisioning.ok = true` — those
are what `/v1/messaging/*` actually gates on. If only `sinch.ok` is
true and `sinch_provisioning` is false, the ISV is missing
`sinch_project_id`; send them to `/isv/integrations` to set it
before continuing.

**2. Buy a number.** Search inventory, pick one, purchase.
```http
POST /v1/messaging/numbers/search   { "areaCode": "415", "numberType": "local", "capabilities": ["sms"] }
POST /v1/messaging/numbers/buy      { "e164": "+14155551234", "capabilities": ["sms"], "smsInboundWebhookUrl": "..." }
```
Resulting `messaging_numbers` row has `status: "active"`.

**3. Pick a path: 10DLC long-code OR toll-free.**

- 10DLC (default for local numbers): go to step 4a.
- Toll-free (skip campaigns, use TFV): go to step 4b.

**4a. 10DLC: register a brand → register a campaign → attach the
number.**
```http
POST /v1/messaging/brands           { merchant identity, EIN, address, contact }
POST /v1/messaging/brands/{id}/refresh-status   # poll until status='approved' (1–8 weeks live, ~5s in test_mode)
POST /v1/messaging/campaigns        { brandId, useCase, messageSamples, optOutKeywords, ... }
POST /v1/messaging/campaigns/{id}/refresh-status # poll until status='approved'
POST /v1/messaging/campaigns/{id}/numbers       { "messagingNumberIds": ["brz_msgnum_..."] }
```
Failure modes: `409 brand_not_approved` (campaign created before brand
approved), `failed` status with `failureReasons` array on the row
(brand or campaign), partial-success body on bulk number attach.

**4b. Toll-free: submit a verification, attach the number.**
```http
POST /v1/messaging/tollfree-verifications  { messagingNumberId, businessName, useCaseSummary, optInImageUrls, ... }
POST /v1/messaging/tollfree-verifications/{id}/refresh-status # until 'approved'
```
The verification row carries the linked `messagingNumberId` so the SMS
gate finds it without an explicit attach call.

**5. (One-time, optional but recommended) Get the merchant on a
messaging plan.** If the ISV is in `subscription_tiers` mode, the
merchant must pick a tier before SMS can bill. Surface a tier picker;
on submit:
```http
PATCH /v1/messaging/settings { "messaging_tier_id": <id> }
```
In `volume_bands` mode (default), no merchant action needed — the
band picks itself based on monthly volume.

**6. Send.**
```http
POST /v1/messages/sms { "to": "+14155551234", "body": "Hi Jane..." }
```
The gate will pick the merchant's first approved sender. Pass `from`
explicitly if you want to pin one. Errors you'll handle:
- `409 messaging_not_approved` — campaign not approved yet
- `409 tollfree_not_verified` — TFV not approved yet
- `402 quota_exceeded` — monthly cap hit (raise via PATCH /v1/messaging/settings)
- `402 not_supported_in_plan` — international destination (US only)
- `403 mms_disabled` — request had `mediaUrls` and ISV has MMS off

**Webhooks to listen for.** Drive UI status updates off these instead
of polling refresh-status more aggressively than 1/min:
- `messaging.brand.{submitted,approved,failed}`
- `messaging.campaign.{submitted,approved,failed,numbers_attached,number_detached}`
- `messaging.tfv.{submitted,approved,rejected}`
- `messaging.number.{purchased,released}`
- `messaging.cap_warning` / `messaging.cap_reached`

**Test mode.** Set `merchants.test_mode = true` and every provisioning
call routes through a mock provider that auto-approves brands +
campaigns in ~5s and TFVs in ~10s. Lets you exercise the entire
end-to-end UI flow in 30 seconds without a Twilio / Sinch account.

**Status state machines (for rendering in UI):**
- Brand: `draft → submitted → in_review → approved | failed | suspended`
- Campaign: same as brand
- TFV: `draft → submitted → in_review → approved | rejected | more_info_needed`
- Number: `active | suspended | released`

**Key ID prefixes (so you can format friendly displays):**
- `brz_msgnum_*` — phone number row
- `brz_brand_*` — 10DLC brand
- `brz_camp_*` — 10DLC campaign
- `brz_tfv_*` — toll-free verification

**ISV billing knobs (already wired — see 4.11 / 4.12).** When the ISV
configures their fee schedule at `/isv/messaging-billing`, brand /
campaign / TFV / number-activation approvals AUTOMATICALLY trigger
ledger entries; no extra calls needed from this flow.

**Why this exists.** US carriers require every long-code SMS sender to
register a brand (the legal entity sending) and a campaign (what kind
of messages, with which opt-in / opt-out language) before any traffic
is allowed. Toll-free numbers go through a separate verification
process. Without it, messages are filtered or charged at penalty
rates. The endpoints here automate the paperwork.

#### Phone numbers — search → buy → release

```http
POST /v1/messaging/numbers/search
{
  "provider": "twilio",          // optional; defaults to merchant_settings.defaultMessagingProvider
  "countryCode": "US",
  "numberType": "local",          // local | toll_free | mobile
  "areaCode": "415",
  "capabilities": ["voice", "sms"],
  "limit": 20
}
```

Returns `{ provider, results: [{ e164, friendlyName, monthlyPriceCents, capabilities, ... }] }`.
No charge until you call `/buy`.

```http
POST /v1/messaging/numbers/buy
{
  "provider": "twilio",
  "e164": "+14155551234",
  "capabilities": ["voice", "sms"],
  "smsInboundWebhookUrl": "https://yourapp.com/incoming-sms",
  "voiceInboundWebhookUrl": "https://yourapp.com/incoming-voice"
}
```

Returns the full `messagingNumber` row with `status: "active"`. Errors:
`409 number_already_owned`, `402 provider_billing_failed`, `502` on
provider unreachable.

```http
DELETE /v1/messaging/numbers/{id}    # release back to provider
PATCH  /v1/messaging/numbers/{id}    # update inbound webhook URLs
GET    /v1/messaging/numbers          # list (filter by ?status, ?campaignId, ?provider)
GET    /v1/messaging/numbers/{id}     # detail
```

#### A2P 10DLC: register a brand, then a campaign

The carriers want two separate registrations: the **brand** (legal
entity behind the messages) and the **campaign** (what the messages are
for). Brands take 1–8 weeks to vet at TCR; campaigns are usually
approved within hours of an approved brand.

```http
POST /v1/messaging/brands
{
  "provider": "twilio",
  "legalName": "Acme Self Storage LLC",
  "brandName": "Acme Storage",          // DBA — what tenants see
  "taxId": "123456789",                  // EIN — encrypted server-side, only last-4 returned
  "entityType": "PRIVATE_PROFIT",        // PRIVATE_PROFIT | PUBLIC_PROFIT | NON_PROFIT | GOVERNMENT | SOLE_PROPRIETOR
  "vertical": "REAL_ESTATE",             // TCR vertical catalog
  "website": "https://acmestorage.com",
  "street": "123 Main St", "city": "Austin", "region": "TX", "postalCode": "78701",
  "contactFirstName": "Jane", "contactLastName": "Doe",
  "contactEmail": "jane@acmestorage.com", "contactPhone": "+15125550100",
  "submit": true                         // default true — runs provider submission inline
}
```

Status path: `draft` → `submitted` → `in_review` → `approved` (or
`failed` with `failureReasons`). Poll with
`POST /v1/messaging/brands/{id}/refresh-status` (rate-limited to
1/min/brand). The background cron also polls every 15 minutes. When the
brand transitions, a `messaging.brand.approved` / `.failed` webhook
fires.

```http
POST /v1/messaging/campaigns
{
  "brandId": "brz_brand_...",            // must be APPROVED
  "useCase": "account_notification",     // see enum: customer_care, marketing, delivery_notification,
                                         // account_notification, two_factor_authentication, polling_voting,
                                         // low_volume, mixed
  "description": "Send rent reminders, payment confirmations, and account alerts to storage tenants who opted in.",
  "messageFlow": "Tenants opt in by checking a box on the lease agreement at acmestorage.com/lease.",
  "messageSamples": [
    "Hi {name}, your rent of ${amount} is due on {date}. Reply PAY to charge your card on file.",
    "Payment of ${amount} received. Receipt: {url}"
  ],
  "helpMessage": "Reply with questions to our office at +15125550100, or visit acmestorage.com/help.",
  "optOutKeywords": ["STOP", "CANCEL"], // default ["STOP"]
  "optOutMessage": "You are unsubscribed. Reply HELP for help.",
  "submit": true
}
```

Returns `409 brand_not_approved` if the brand isn't approved yet.
Status path mirrors brands; refresh with
`POST /v1/messaging/campaigns/{id}/refresh-status`.

Once the campaign is `approved`, attach numbers:

```http
POST /v1/messaging/campaigns/{id}/numbers
{ "messagingNumberIds": ["brz_msgnum_aaa", "brz_msgnum_bbb"] }
```

Bulk attach, with per-id success/failure reporting. Errors include
`number_belongs_to_other_campaign`, `provider_mismatch`,
`number_not_active`. `DELETE /v1/messaging/campaigns/{id}/numbers/{numberId}`
detaches one.

#### Toll-free verification (alternative to 10DLC)

Toll-free numbers can skip the brand+campaign dance via the carriers'
TFV path. Faster but less throughput.

```http
POST /v1/messaging/tollfree-verifications
{
  "messagingNumberId": "brz_msgnum_...", // must be number_type=toll_free
  "businessName": "Acme Self Storage LLC",
  "businessWebsite": "https://acmestorage.com",
  "notificationEmail": "ops@acmestorage.com",
  "useCaseCategories": ["TWO_FACTOR_AUTHENTICATION", "ACCOUNT_NOTIFICATION"],
  "useCaseSummary": "Send 2FA login codes and rent-due reminders.",
  "productionMessageSample": "Your Acme Storage code is 123456. Don't share it.",
  "optInImageUrls": ["https://acmestorage.com/optin-screenshot.png"],
  "optInType": "WEB_FORM",               // VERBAL | WEB_FORM | PAPER_FORM | VIA_TEXT | MOBILE_QR_CODE
  "estimatedMonthlyVolume": 5000,
  "street": "123 Main St", "city": "Austin", "region": "TX", "postalCode": "78701",
  "submit": true
}
```

Status path: `draft` → `submitted` → `in_review` → `approved` |
`rejected` | `more_info_needed`. The route also flips the linked
`messagingNumber.tollfreeVerificationId` on submit so the SMS gate
(below) finds it.

#### Voice — outbound + inbound

```http
POST /v1/messaging/voice/calls
{
  "from": "brz_msgnum_...",              // OR raw e164 owned by merchant
  "to":   "+14155551234",
  "twimlUrl": "https://yourapp.com/twiml/call.xml"
  // OR provide inline: { twiml: "<Response><Say>...</Say></Response>" }
  // Sinch equivalents: svamlUrl, svaml
}
```

Inbound voice goes to whatever you set on the number's
`voiceInboundWebhookUrl` — the provider POSTs straight to your URL.
Tiyo doesn't host an IVR.

#### SMS sending — gated on approval

`POST /v1/messages/sms` is the same endpoint you use for arbitrary
sends, but it now enforces an approval gate when
`merchant_settings.enforceMessagingApproval = true` (default):

- Long-code (`number_type='local'`) `from` must be attached to an
  APPROVED campaign — otherwise `409 messaging_not_approved` with
  `campaignStatus` in the body.
- Toll-free `from` must have an APPROVED TFV — otherwise
  `409 tollfree_not_verified` with `verificationStatus` in the body.
- Non-US mobile numbers skip the gate.

```http
POST /v1/messages/sms
{
  "to": "+14155551234",
  "body": "Hi Jane, your rent is due Friday. Reply STOP to opt out.",
  "from": "brz_msgnum_..."   // optional — defaults to first approved sender
}
```

Returns `{ success, channel: "sms", provider, id, from, messagingNumberId }`.

**To bypass the gate for testing**, an admin can
`PATCH /v1/settings { enforceMessagingApproval: false }`. Logged in
audit. Don't ship production traffic with the gate disabled — the
carriers will filter.

### 4.11 Messaging Billing & Caps

A separate concern from approval gating. The gateway *meters* every
dispatched and received SMS/MMS, *enforces* a per-merchant monthly
cap, and *bills* the merchant for usage on their next invoice.

#### Tiers

| Slug      | Monthly base | Included SMS | Overage SMS | MMS      |
|-----------|--------------|--------------|-------------|----------|
| `starter` | $0           | 100          | $0.05       | $0.10    |
| `growth`  | $15          | 500          | $0.04       | $0.10    |
| `volume`  | $40          | 2,000        | $0.03       | $0.08    |

Rates stored as millicents (1000 = 1¢) in `messaging_tiers` —
admins can adjust via `PATCH /v1/admin/messaging/tiers/{id}`.

#### Cap enforcement

Before `POST /v1/messages/sms` dispatches, the gateway checks the
merchant's month-to-date usage against `messaging_monthly_cap_messages`
(default 500) and `messaging_monthly_cap_dollars` (default $25). If
either is exhausted, the request rejects with **HTTP 402
`quota_exceeded`**:

```json
{
  "error": "quota_exceeded",
  "reason": "messages",          // or "dollars"
  "messages_used": 500,
  "messages_cap": 500,
  "dollars_used_cents": 1875,
  "dollars_cap_cents": 2500,
  "next_reset_at": "2026-05-01T00:00:00.000Z"
}
```

International destinations (anything not `+1` or 10–11-digit US
format) reject with **HTTP 402 `not_supported_in_plan`**.

Metering happens *after* a successful dispatch so a failed send
doesn't burn quota.

#### Spend alerts

The gateway fires once-per-period at 80% and 100% of either cap:

- `messaging.cap_warning` webhook + email at 80%
- `messaging.cap_reached` webhook + email at 100% (cap_reached
  coincides with the first 402)

Both events carry the same `{ reason, messages_used, messages_cap,
dollars_used_cents, dollars_cap_cents, period }` envelope. Email
goes to `messaging_alert_email` if set on settings, falls back to
the merchant's primary contact otherwise. `messaging_alert_state`
has a `UNIQUE (merchant_id, billing_period, threshold)` constraint
so each threshold fires exactly once even under concurrent writes.

#### Monthly invoice rollover

Wired into the existing recurring-billing cron. On day-of-month=1
at the merchant's `preferred_billing_hour`, the gateway sums the
prior month's usage (tier base + SMS overage + MMS) and appends a
line item to the merchant's draft invoice, prefixed with
`[messaging-billing:YYYY-MM]` in the line `notes` field as the
dedupe key.

#### Inspection + admin

Merchants:

```http
GET /v1/messaging/usage
PATCH /v1/messaging/settings
{
  "messaging_monthly_cap_messages": 1000,
  "messaging_alert_email": "ops@acme.com"
}
```

`GET /v1/messaging/usage` returns the same `{ period, tier, cap,
summary }` shape the dashboard widget renders. Cap edits are
limited to ≤10,000 from the merchant scope; higher requires
admin/ISV via `/v1/admin/messaging/...`.

Admin/ISV:

| Method | Path | Purpose |
|--------|------|---------|
| GET    | `/v1/admin/messaging/tiers` | List tiers |
| PATCH  | `/v1/admin/messaging/tiers/{id}` | Adjust tier rates (admin only) |
| GET    | `/v1/admin/messaging/usage?merchant_id=brz_mer_...` | Inspect any merchant |
| PATCH  | `/v1/admin/messaging/merchants/{id}/messaging-tier` | Assign a tier (admin/ISV with access) |

### 4.12 ISV-scoped messaging fee schedule

The platform tier (4.11) is the floor. Each ISV layers their own
pricing program on top: one-time provisioning fees, monthly
recurring fees per number / per campaign, and optional per-message
rate overrides. Sinch / Twilio bill the ISV; the ISV bills the
merchant via Tiyo Pay's invoice rollover.

#### What gets charged when

| Trigger | Fee | Where it lands |
|---------|-----|----------------|
| `messaging_brands.status` flips to `approved` | `brand_registration_fee_cents` | `messaging_fee_charges` (one-time) |
| `messaging_campaigns.status` flips to `approved` | `campaign_registration_fee_cents` | `messaging_fee_charges` (one-time) |
| `messaging_tollfree_verifications.status` flips to `approved` | `tollfree_verification_fee_cents` | `messaging_fee_charges` (one-time) |
| `messaging_numbers` purchased (status=`active`) | `number_activation_fee_cents` | `messaging_fee_charges` (one-time) |
| Day-1 rollover, every active number | `number_monthly_fee_cents` | `messaging_fee_charges` (period_ym) |
| Day-1 rollover, every approved campaign | `campaign_monthly_fee_cents` | `messaging_fee_charges` (period_ym) |

Idempotency: UNIQUE
`(merchant_id, fee_type, reference_type, reference_id, period_ym)`
on `messaging_fee_charges`. A duplicate webhook, retried refresh,
or re-run cron can't double-charge.

#### When fees become billable (timing toggles)

Two ISV-controlled flags on `isv_messaging_fees` decide WHEN a fee
turns into a real invoice:

- `setup_fee_billing_mode`:
  - `next_invoice` (default) — brand / campaign / TFV / number-activation fees accumulate in `messaging_fee_charges` and roll into the merchant's monthly invoice on day 1.
  - `immediate` — the moment a fee fires, open a one-line draft invoice for it. Better for cash-flow; merchant sees the bill within minutes of the approval.
- `subscription_billing_mode` (subscription_tiers mode only):
  - `arrears` (default) — merchant picks a tier on day 5; tier flat fee shows up on the next monthly invoice.
  - `prepay` — merchant picks a tier; immediately get an invoice for that period's flat fee. Subsequent periods bill on the normal monthly cadence — the UNIQUE on `(merchant_id, fee_type, reference_type, reference_id, period_ym)` ensures the day-1 rollover sees the already-invoiced row and skips.

Subscription tier flat fees live on `messaging_fee_charges` as
`fee_type='subscription_period'`, `reference_type='merchant'`,
`reference_id=merchant.id`, `period_ym='YYYY-MM'`. The rollover cron
calls `accrueSubscriptionPeriod(merchantId, prevYm)` alongside
`accrueRecurringFees` before sweeping.

#### Pricing modes

Each ISV picks one of two billing models via
`isv_messaging_fees.pricing_mode`:

**`volume_bands` (default)** — auto-picked tier. The merchant's
monthly SMS count picks the smallest tier whose ceiling >= count;
that flat fee + overage above the top tier. ISV captures revenue
based on actual usage. With tiers `1–300 = $0`, `301–500 = $5`,
`501–1000 = $10` and overage rate `$0.05/msg`: 800 SMS = $10. 1,200
SMS = $10 + 200 × $0.05 = $20.

**`subscription_tiers`** — merchant picks one tier upfront via
`PATCH /v1/messaging/settings { messaging_tier_id }`. They're
charged that tier's flat fee EVERY period, even at zero usage —
overage applies only above the tier's ceiling. ISV captures revenue
when merchants under-utilize their plan. Tiers should be named
("Basic", "Pro", "Enterprise") via the band row's `name` column —
the dashboard uses that as the plan label in the merchant's tier
picker.

Same `isv_messaging_volume_bands` rows serve both modes; only the
interpretation at rollover differs. In subscription mode, when the
ISV has set up tiers but the merchant hasn't picked one yet,
`MessagingUsageCard` on the merchant's home shows a tier picker and
SMS billing is suspended until they pick.

#### Resolution + cost computation

- `service-messaging-billing.resolveIsvPricing(merchantId)` returns `{ bands, smsOverageMillicents, mmsMillicents }`.
- `computeSmsBillCents(count, pricing)` returns `{ bandCents, overageCents, overageCount }`.
- SMS metering rows write `merchant_cost_cents=0`; SMS cost is computed at rollover time.
- MMS metering rows write `merchant_cost_cents = mms_millicents / 100` at send time.
- Cap enforcement projects the running cost in `checkMessagingCap` so the dollar cap kicks in correctly.

When the ISV hasn't configured any bands AND `sms_overage_millicents = 0`, every SMS is effectively free until they set up.

#### Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| GET   | `/v1/admin/messaging/fees` | ISV reads own provisioning fees; admin uses `?developer_id=` |
| PATCH | `/v1/admin/messaging/fees` | Upsert provisioning fees + SMS overage + MMS rate |
| GET   | `/v1/admin/messaging/tiers` | List the ISV's volume bands |
| POST  | `/v1/admin/messaging/tiers` | Add a band: `{ up_to_messages, price_cents }` |
| PATCH | `/v1/admin/messaging/tiers/{id}` | Update a band (owner-checked) |
| DELETE| `/v1/admin/messaging/tiers/{id}` | Drop a band |
| GET   | `/v1/messaging/fees` | Merchant read-only view of the provisioning schedule |
| GET   | `/v1/messaging/upcoming-charges` | Pending fee charges for next monthly invoice |
| PATCH | `/v1/admin/messaging/merchants/{id}/caps` | Raise a specific merchant's monthly cap above the 10k self-service ceiling |

#### Rollover order on day-1

```
runMessagingBilling()
  for each merchant whose local time = day 1 + preferredBillingHour:
    accrueRecurringFees(merchantId, ym)
      → walks active numbers + active campaigns
      → INSERTs pending rows into messaging_fee_charges (idem-skipped on dup)
    monthlyMessagingRollover(merchantId)
      → finds/opens [messaging-billing:YYYY-MM] draft invoice
      → if SMS activity: appends metering line item
      → for each pending fee_charge: appends line item, flips status='invoiced'
      → updates invoice subtotal+total
```

When a merchant has no SMS activity but has pending fees (e.g.,
brand approved last month), the invoice is still opened and the
fees roll forward — they don't strand in the ledger.

#### Test mode

Merchants in `test_mode=true` route through a mock messaging provider:
brand and campaign approval auto-completes ~5–10s after submit (vs
1–8 weeks live), `searchNumbers` returns five fake `+1<area>5550000…`
e164s, `buyNumber` mints `mock_PN_<uuid>`, `sendSms` returns
`mock_SM_<uuid>` without firing real traffic. Lets you exercise the
full gated flow end-to-end in 30 seconds.

---

## 5. POS integration pattern

This is the recommended flow for a custom POS / register / kiosk.

### 5.1 Boot: two parallel calls

```http
GET /v1/catalog/all                        # one response, every catalog entity
GET /v1/products?pageSize=100&expand=department,category,group,modGroups
```

`GET /v1/catalog/all` returns:

```json
{
  "departments":     [ ... ],
  "categories":      [ ... ],
  "productGroups":   [ ... ],
  "classifications": [ ... ],
  "suppliers":       [ ... ],
  "modGroups":       [ ... ],
  "modifiers":       [ ... ],
  "now":             "2026-04-23T14:05:12.031Z"
}
```

`GET /v1/products?expand=...` returns products with related resources
inlined. `expand` keys: `department`, `category`, `group`, `classification`,
`supplier`, `modGroups`. `modGroups` also inlines modifiers inside each
mod group. Response envelope carries `now`.

**Save both `now` values as your sync cursors.**

### 5.2 Incremental sync

Every N seconds (recommend 30 s–5 min depending on volume):

```http
GET /v1/catalog/all?updated_since=<cursor>
GET /v1/products?updated_since=<cursor>&expand=department,category,group,modGroups
```

Only rows whose `updated_at` is strictly after the cursor come back. The
`now` in the response is your next cursor — no clock-drift math.

### 5.3 Product shape with `expand`

```json
{
  "id": "brz_prd_...",
  "name": "Climate-Controlled Large (10x15)",
  "product_code": "CCL-10x15",
  "department_id": 3,
  "category_id": 7,
  "product_group_id": 2,
  "classification_id": null,
  "supplier_id": null,
  "unit_cost": 0,
  "cash_price": 20000,       // cents
  "card_price": 20800,       // cents (merchant markup applied)
  "stock_level": null,       // null = not tracked
  "reorder_point": null,
  "enabled": true,
  "has_variants": false,
  "allow_discounts": true,
  "open_price": false,
  "price_editable": false,
  "mod_group_ids": [6],
  "createdAt": "...",
  "updatedAt": "...",
  "department_name": "Storage",      // lightweight sidecar (always on)
  "category_name": "Climate",        // lightweight sidecar
  "group_name": "Indoor",            // lightweight sidecar
  "department": { ... full row },    // present only when ?expand=department
  "category":   { ... full row },    // ?expand=category
  "group":      { ... full row },    // ?expand=group
  "mod_groups": [                     // ?expand=modGroups
    { "id": 6, "name": "Access Hours", "required": false, ..., "modifiers": [ ... ] }
  ]
}
```

### 5.4 Taking a sale at the POS

Two common patterns:

**(a)** Card-present (physical terminal). Call `POST /v1/payments` with
`mode: "cp"` and your computed total in cents. The server waits while the
customer taps; you get the final status back on the same request.

**(b)** Card-not-present (manual entry, customer-not-at-register). Mint a
checkout session with `skip_customer_info: true` (since your POS already
knows who the customer is) and display the hosted form inline or on a
second device.

### 5.5 Ancillary POS endpoints

- `GET /v1/customers?search=jane` — live customer lookup
- `POST /v1/customers` — create on the fly
- `GET /v1/customers/{id}/cards` — saved cards to offer
- `GET /v1/discounts` — preset discounts (auto-apply + schedule aware)
- `GET /v1/settings` — merchant-wide config: `card_markup_percent`,
  `cash_discount_percent`, `tax_rate` (basis points × 100), timezone, etc.
- `POST /v1/batches/{id}/close` + `POST /v1/batches/{id}/settle` — daily
  close-out

---

## 5a. Terminals (POS pairing + realtime)

A **terminal** is a POS software instance — a cash register, tablet, or
phone running your app. Distinct from the physical Dejavoo card reader
(which is already paired in the merchant's Dejavoo portal and unchanged
by any of this). Each terminal has its own long-lived device secret so
a merchant can revoke one register without taking the others offline,
and every transaction it rings is attributable via `transactions.terminal_id`.

### 5a.1 Pair a register (one-time setup)

**Step 1 — dashboard user mints a code:**

```http
POST /v1/terminals/pair-codes
Authorization: Bearer <dashboard-jwt>
{ "suggested_name": "Register 2", "expires_in": 600 }
```

Response: `{ "code": "K7MQ-3VRX", "expires_at": "...", "expires_in": 600 }`

Codes are 8 alphanumeric chars (ambiguous glyphs 0/O/1/I/L excluded),
displayed as `ABCD-EFGH`. TTL 60 s–3600 s (default 600 s).

> **UI shortcut:** dashboard users don't have to curl this. **Merchants → *(merchant)* → Integrate** → **Pair new terminal**. The code renders in a high-contrast card with copy button + expiry, the paired terminal list auto-loads below with last-seen timestamps, and each row has a Revoke action.

**Step 2 — POS redeems it (NO auth header):**

```http
POST /v1/terminals/pair
Content-Type: application/json
{
  "code": "K7MQ-3VRX",
  "name": "Register 2",
  "device_info": { "platform": "iOS 19.2", "appVersion": "1.4.0" }
}
```

Response:

```json
{
  "terminal": { "id": "brz_term_...", "merchantId": "brz_mer_...", ... },
  "device_secret": "brz_term_sec_7f3a8b1c..."
}
```

**`/v1/terminals/pair` is the ONE endpoint in the entire API that takes
no bearer auth** — the pair code itself is the auth. Save the
`device_secret` immediately (OS keychain / secure storage). Returned
once, never retrievable again.

### 5a.2 Authenticate with the device secret

Every subsequent request:

```http
GET /v1/products
Authorization: Bearer brz_term_sec_7f3a8b1c...
```

No `/v1/auth/token` exchange needed. No JWT refresh loop. The secret is
long-lived until revoked.

The `Authorization: Bearer brz_term_sec_...` prefix is how the gateway
detects terminal auth. It auto-resolves the terminal → merchant, stamps
`terminalId` on the request context, and bumps `terminals.last_seen_at`.

### 5a.3 Bootstrap endpoint

One fat endpoint the POS hits on cold-start:

```http
GET /v1/terminal/bootstrap
Authorization: Bearer brz_term_sec_...
```

Response:

```json
{
  "merchant":   { "id": "brz_mer_...", "name": "...", "status": "active", "testMode": false },
  "settings":   { "cardMarkupPercent": 4, "taxRate": 625, "tippingEnabled": true, "tipPresets": [15,18,20], ... },
  "catalog": {
    "departments":     [ ... ],
    "categories":      [ ... ],
    "productGroups":   [ ... ],
    "classifications": [ ... ],
    "suppliers":       [ ... ],
    "modGroups":       [ ... ],
    "modifiers":       [ ... ]
  },
  "products":   [ /* full catalog — names inlined via department_name/category_name/group_name */ ],
  "discounts":  [ ... ],
  "customers":  [ /* up to 500 most recent */ ],
  "paymentProviders": {
    "cardCnp": [
      { "id": 7, "name": null, "provider": "epx_north", "mode": "cnp",
        "isActive": true, "dejavooTpn": null, "registerId": null }
    ],
    "cardCp":  [
      { "id": 42, "name": "Front Counter", "provider": "dejavoo", "mode": "cp",
        "isActive": true, "dejavooTpn": "TPN111", "registerId": null },
      { "id": 43, "name": "Drive-Thru",    "provider": "dejavoo", "mode": "cp",
        "isActive": true, "dejavooTpn": "TPN222", "registerId": null }
    ],
    "ach":     [
      { "id": 51, "name": null, "provider": "linked2pay", "mode": "cnp",
        "isActive": true, "dejavooTpn": null, "registerId": null }
    ]
  },
  "now":        "2026-04-23T14:05:12.031Z",
  "incremental": false
}
```

**Picking a physical terminal:** `paymentProviders.cardCp` is the
POS terminal picker source. Render the list, let the cashier select
one, then pass the row's `id` as `physical_terminal_id` on
`POST /v1/payments` (or pass the `dejavooTpn` as `dejavoo_tpn` —
either works). If a merchant only has one CP terminal, or the POS
was paired with a default `terminals.dejavoo_tpn`, the pin is
optional.

**Incremental sync** — poll every 30 s to 5 min:

```http
GET /v1/terminal/bootstrap?updated_since=<last-cursor>
```

Only rows whose `updated_at` is strictly after the cursor come back.
`incremental: true`. **Customers are omitted from incremental
responses** (they change often, rebuilding a 10k-row list every minute
is wasteful). Save the new `now` as the next cursor.

Products are **unbounded** on full sync — the register needs the full
catalog to ring sales offline. If you ever hit merchants with >50k
SKUs we'll add pagination.

### 5a.4 Realtime push (optional)

Replace the 30 s poll with a WebSocket. Uses Supabase Realtime (already
running as part of the Supabase project — no custom WS server).

**Mint a realtime JWT:**

```http
POST /v1/terminals/realtime-token
Authorization: Bearer brz_term_sec_...
{ "expires_in": 3600 }
```

Response:

```json
{
  "token": "eyJ...",
  "expires_in": 3600,
  "merchant_id": "brz_mer_...",
  "realtime_url": "https://crwbtzryadlpmlupgcmz.supabase.co/realtime/v1",
  "channel": "merchant:brz_mer_..."
}
```

**Subscribe** (Node / browser):

```ts
import { createClient } from "@supabase/supabase-js";

const sb = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
sb.realtime.setAuth(realtimeJwt);

sb.channel(`merchant:${merchantExternalId}`)
  .on("postgres_changes", { event: "*", schema: "public", table: "products" },
      (payload) => applyProductChange(payload))
  .on("postgres_changes", { event: "*", schema: "public", table: "discounts" },
      (payload) => applyDiscountChange(payload))
  .on("postgres_changes", { event: "*", schema: "public", table: "merchant_settings" },
      (payload) => applySettingsChange(payload))
  .subscribe();
```

Tables enabled for Realtime: `products`, `discounts`, `merchant_settings`,
`departments`, `categories`, `product_groups`, `classifications`,
`suppliers`, `modifier_groups`, `modifiers`, `terminals`. Each payload
has `eventType` (INSERT/UPDATE/DELETE), `new`, `old`.

RLS policies on all those tables enforce `merchant_id = jwt_merchant_id()`
— a compromised terminal can't subscribe to another merchant's rows.

**Always keep the updated_since poll running at a slow cadence (e.g. 5 min)
as a safety net** — WebSocket connections drop during network blips and
can silently miss events. The poll catches anything the stream missed.

### 5a.5 Manage terminals

```http
GET    /v1/terminals                      # List merchant's terminals
GET    /v1/terminals/{id}                 # Retrieve
PATCH  /v1/terminals/{id}                 # Rename, pin TPN, update config, deactivate
DELETE /v1/terminals/{id}                 # Revoke (device secret stops working)
```

### 5a.6 Sale attribution

Every `POST /v1/payments` made with a terminal device secret
automatically stamps `transactions.terminal_id`. Existing API-key /
dashboard traffic stays attributed to `terminal_id: null` — no
migration needed, zero breaking change.

---

## 6. Catalog resources

All merchant-scoped. All accept `?updated_since=<ISO>` now.

```http
GET /v1/catalog?entity=departments        # { data: [...], now }
GET /v1/catalog?entity=categories
GET /v1/catalog?entity=product-groups
GET /v1/catalog?entity=classifications
GET /v1/catalog?entity=suppliers
GET /v1/catalog?entity=mod-groups
GET /v1/catalog?entity=modifiers[&mod_group_id=N]
```

Write endpoints for each:

```http
POST   /v1/catalog?entity=<type>   { name, ... }
PATCH  /v1/catalog/{id}?entity=<type>   { ...partial }
DELETE /v1/catalog/{id}?entity=<type>
```

Modifiers additionally require `mod_group_id` on create.

### POS-authoritative write pattern

If you're building a register that lets cashiers create items or modifiers
on the fly, **write to Tiyo first, then mirror to the local DB** — not the
other way around. Tiyo must own the row so:

* Every other paired terminal picks it up on the next `/v1/terminal/bootstrap`
  poll automatically.
* Receipts, reports, refunds, and repeat charges can reference the real id.
* Re-syncing or onboarding a new terminal rebuilds state from Tiyo alone.

```js
async function createModifier({ modGroupId, name, priceAdjustmentCents }) {
  // 1. Tiyo writes the authoritative row.
  const res = await fetch(`${GATEWAY}/v1/catalog?entity=modifiers`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${terminalSecret}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      mod_group_id: modGroupId,
      name,
      price_adjustment: priceAdjustmentCents,
      enabled: true,
    }),
  });
  if (!res.ok) throw new Error((await res.json()).message);
  const saved = await res.json();

  // 2. Mirror locally so the cashier's UI is immediate.
  await localDb.modifiers.upsert({ tiyoId: saved.id, ...saved });
  return saved;
}
```

Same pattern for products, departments, categories, modifier groups, etc.
Never let a local-only row exist — a terminal rebuild from bootstrap will
never see it, and any transaction that references it loses context the
moment the local DB is wiped.

If the register is offline when the cashier hits Save, queue the write
and retry on reconnect. Never accept a local-only create as "done."

---

## 7. Endpoint reference (exhaustive)

All require `Authorization: Bearer <jwt>` unless noted. List endpoints
default to `?page=1&pageSize=20` (max 100).

### Auth

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/auth/token` | Exchange API key or refresh token for a JWT (no auth) |
| POST | `/v1/auth/revoke` | Revoke a JWT or refresh token |
| GET  | `/v1/me` | Caller identity + active merchant + branding |
| GET  | `/v1/me/accessible-merchants` | List merchants caller can scope to |
| GET  | `/v1/me/api-logs` | Paginated API request log for this merchant |
| GET  | `/v1/me/branding` | ISV-only: own brand row |
| PUT  | `/v1/me/branding` | ISV-only: update brand |
| POST | `/v1/me/branding/extract` | Best-effort brand autofill from a URL |

### Payments

| Method | Path | Purpose |
|---|---|---|
| GET  | `/v1/payments` | List transactions (filter: `status`, `type`) |
| POST | `/v1/payments` | Charge a card (`mode`: `cnp` default or `cp`). **Idempotency-Key required.** |
| GET  | `/v1/payments/{id}` | Retrieve. `?expand=customer` inlines customer. |
| GET  | `/v1/payments/{id}/history` | Audit trail for this transaction |
| POST | `/v1/payments/{id}/void` | Void pre-settlement (host-side; never contacts the terminal). Failed voids return `voidStatus: "failed"` and leave the original sale `approved`. |
| POST | `/v1/payments/{id}/refund` | `{ amount? }` — omit for full refund. Host-side by RRN; never contacts the terminal. |
| POST | `/v1/payments/{id}/repeat` | Rerun a prior sale with its stored reusable token |
| POST | `/v1/payments/{id}/adjust-tip` | `{ tip: <cents> }` |
| POST | `/v1/payments/{id}/receipt` | Email / SMS a receipt |
| POST | `/v1/payments/tokenize` | Mint a reusable card token via $0 Auth-Only ($1 auth + reversal fallback). No funds held. Replaces the old $1 sale + auto-void path that was invalidating chdTokens on Dejavoo UAT. |
| POST | `/v1/admin/cards/revalidate` | `{ merchantId, limit? }` — sweep saved tokens through `tokenize()`, mark rejects with `invalidated_at`. Auto-pay skips invalidated cards and emits `payment_method.invalid`. |
| POST | `/v1/admin/cards/test-mint` | Admin/developer + `test_mode` merchants only. `{ customerId, merchantId?, brand, exp_month, exp_year, last4?, isDefault? }` — inserts a saved card row with a synthetic `BRZTEST_` chdToken. Skips the processor; subsequent charges in test_mode auto-approve via the synthetic-token shortcut in `createPayment`. |
| POST | `/v1/admin/cards/test-mint-bulk` | `{ merchantId, customerIds: [...], brand, exp_month, exp_year, isDefault? }` — batched mint. Returns `{ minted, failed, cards, failures }`. |
| POST | `/v1/customer-subscriptions` (`bundle_key`) | Optional `bundle_key` field. Subs with the same `(customer_id, bundle_key, card_id, next_billing_date)` charge as ONE transaction with a multi-line invoice. One `invoice.paid` event with `metadata.subscription_ids: [...]`. Solo subs (no bundle_key) keep firing independently. |
| POST | `/v1/payments/{id}/refund` (`subscription_id`) | For bundled subscription charges, pass `subscription_id` instead of `amount` to refund just that line's contribution. Looks up `invoice_items.total` for the matching sub on the bundled invoice. |

### Branding

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/logos` | Multipart upload of a merchant logo. Body: `file` (PNG/JPEG/GIF/WebP/SVG, ≤5 MiB) + optional `target` (`logo_url` / `invoice_logo_url` / `both` / `none`). Returns the hosted URL and applies it to merchant_settings when target is set. |
| POST | `/v1/logos/from-url` | `{ url, target? }` — fetch a logo from a public URL, rehost on Tiyo, and (optionally) apply. Use this after `/v1/me/branding/extract` so the persisted URL is permanent (extracted URLs from platform sites are often short-lived pre-signed S3 URLs). |
| POST | `/v1/me/branding/extract` | `{ url }` — scrape a public website for name / logoUrl / primaryColor. Read-only; chain into `/v1/logos/from-url` to persist a permanent copy. |

### Docs (public, no auth)

| Method | Path | Purpose |
|---|---|---|
| GET  | `/v1/openapi.json` | OpenAPI 3.1 spec for the entire gateway. |
| GET  | `/v1/llm.md` | This integration guide as `text/markdown`. |
| GET  | `/v1/integration-guide.md` | Alias of `/v1/llm.md`. |
| GET  | `/v1/llm.json` | Combined bundle: this guide + the OpenAPI spec + version metadata in one JSON envelope. |

### Setup Intents (save card without charging)

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/setup-intents` | Create. `{ customer_id, return_url?, expires_in? }` |
| GET  | `/v1/setup-intents/{id}` | Retrieve |

### Customers

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/customers` | List. Filters: `status`, `search` |
| POST   | `/v1/customers` | `{ first_name, last_name, email?, phone?, address*?, ... }` |
| GET    | `/v1/customers/{id}` | Retrieve (adds `cards_count`, `total_spent`) |
| PATCH  | `/v1/customers/{id}` | Partial update |
| DELETE | `/v1/customers/{id}` | Soft delete (cascades: pauses recurring, cancels subs) |
| GET    | `/v1/customers/{id}/cards` | List saved cards |
| POST   | `/v1/customers/{id}/cards` | Add a card manually. Dedups by token, then by fingerprint (`brand + last4 + exp_month + exp_year`). Returns `201` on a fresh insert and `200` on a dedup hit. |
| DELETE | `/v1/customers/{id}/cards/{cardId}` | Delete a card |
| GET    | `/v1/customers/{id}/transactions` | List this customer's transactions |
| GET    | `/v1/customers/{id}/subscriptions` | List this customer's subscriptions |
| POST   | `/v1/customers/{id}/portal-sessions` | Mint a portal URL |

### Products

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/products` | List. `?expand=...`, `?updated_since=...`, filters: `department_id`, `enabled`, `search` |
| POST   | `/v1/products` | Create |
| GET    | `/v1/products/{id}` | Retrieve. `?expand=...` supported |
| PATCH  | `/v1/products/{id}` | Update |
| DELETE | `/v1/products/{id}` | Delete |
| GET    | `/v1/products/{id}/variants` | List variants |
| POST   | `/v1/products/{id}/variants` | Create variant |
| PATCH  | `/v1/products/{id}/variants/{variantId}` | Update variant |
| DELETE | `/v1/products/{id}/variants/{variantId}` | Delete variant |

### Catalog (auxiliary product data)

| Method | Path | Purpose |
|---|---|---|
| GET  | `/v1/catalog/all` | All seven entities in one response. `?updated_since=` supported. |
| GET  | `/v1/catalog?entity=<type>` | List one type. `?updated_since=` supported. |
| POST | `/v1/catalog?entity=<type>` | Create |
| PATCH | `/v1/catalog/{id}?entity=<type>` | Update |
| DELETE | `/v1/catalog/{id}?entity=<type>` | Delete |

Types: `departments`, `categories`, `product-groups`, `classifications`,
`suppliers`, `mod-groups`, `modifiers`.

### Discounts

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/discounts` | List. Filter: `enabled` |
| POST   | `/v1/discounts` | Create |
| GET    | `/v1/discounts/{id}` | Retrieve |
| PATCH  | `/v1/discounts/{id}` | Update |
| DELETE | `/v1/discounts/{id}` | Delete |

Discount shape includes `type` (percentage/fixed), `value`, `applies_to`,
`applicable_ids`, `auto_apply`, `min_purchase`, `max_discount`, and
scheduling (`schedule`, `scheduleStart/End`, `scheduleDays`,
`scheduleStartTime/EndTime`). Merchant timezone applies at compare time.

### Membership plans

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/membership-plans` | List (with active-subscriber count) |
| POST   | `/v1/membership-plans` | Create. `{ name, price, billing_cycle, items?, discount_percent?, duration_type?, duration_value? }` |
| GET    | `/v1/membership-plans/{id}` | Retrieve with items |
| PATCH  | `/v1/membership-plans/{id}` | Update |
| DELETE | `/v1/membership-plans/{id}` | Delete |

### Customer subscriptions

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/customer-subscriptions` | List. Filters: `customer_id`, `plan_id`, `status` |
| POST   | `/v1/customer-subscriptions` | Sell a plan. Charges first cycle if `charge_now` ≠ false |
| GET    | `/v1/customer-subscriptions/{id}` | Retrieve |
| PATCH  | `/v1/customer-subscriptions/{id}` | Update status (`active`/`paused`/`cancelled`/`past_due`) |
| DELETE | `/v1/customer-subscriptions/{id}` | Cancel |

### Recurring billing (simple schedule, no plan)

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/recurring` | List |
| POST   | `/v1/recurring` | `{ customer_id, card_id?, amount, frequency, next_billing_date, total_cycles? }` |
| GET    | `/v1/recurring/{id}` | Retrieve |
| PATCH  | `/v1/recurring/{id}` | Update |
| DELETE | `/v1/recurring/{id}` | Delete |

### Invoices

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/invoices` | List. Filters: `status`, `customer_id` |
| POST   | `/v1/invoices` | Create. `{ customer_id, items[], due_date?, auto_pay?, allow_partial? }` |
| GET    | `/v1/invoices/{id}` | Retrieve with items |
| PATCH  | `/v1/invoices/{id}` | Update (drafts: everything. Sent: `status`, `auto_pay` only) |
| DELETE | `/v1/invoices/{id}` | Drafts only |
| POST   | `/v1/invoices/{id}/send` | Email / SMS |
| POST   | `/v1/invoices/{id}/pay` | Charge a linked card now. `{ card_id? }`. Emits `payment.completed` + `invoice.paid` on success. |
| POST   | `/v1/invoices/test-email` | Preview current invoice branding via a sample |

### Checkout Sessions (hosted payment page)

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/checkout/sessions` | Mint a session. Optional fields: `line_items[]`, `payment_methods[]`, `pricing` (dual-pricing snapshot), `metadata` (caller tags). |
| GET  | `/v1/checkout/sessions/{id}` | Status poll |
| POST | `/v1/checkout/sessions/{id}/complete` | Internal — called by the hosted page |

### Batches

| Method | Path | Purpose |
|---|---|---|
| GET  | `/v1/batches` | List. Filter: `status` |
| GET  | `/v1/batches/{id}` | Retrieve |
| POST | `/v1/batches` | Open a new batch |
| POST | `/v1/batches/{id}/close` | Close (computes totals from transactions) |
| POST | `/v1/batches/{id}/settle` | Mark settled |
| GET  | `/v1/batches/processor` | **Dejavoo only.** Pull settlement data from iPOSpays directly. `?date=YYYY-MM-DD`, `?from=`, `?to=` |

### Webhooks

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/webhooks` | List (filter: `merchantId`) |
| POST   | `/v1/webhooks` | Register. `{ url, events: [...] }`. Secret returned ONCE in the response |
| PATCH  | `/v1/webhooks/{id}` | Update url / events / isActive |
| DELETE | `/v1/webhooks/{id}` | Delete |
| POST   | `/v1/webhooks/{id}/test` | Fire a synthetic `webhook.test` event |
| POST   | `/v1/webhooks/{id}/rotate-secret` | `{ overlap_hours? }`. Returns old + new secrets. Both valid during overlap |
| GET    | `/v1/webhooks/{id}/deliveries` | Delivery history |
| POST   | `/v1/webhooks/{id}/deliveries/{deliveryId}/retry` | Force-retry a failed/exhausted delivery |

### Events (backfill / replay)

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/events` | List. Filters: `type`, `resource_id`, `after`, `before`. Cursor-paginated (`starting_after`, `limit`, response: `has_more`, `next_cursor`) |
| GET | `/v1/events/{id}` | Retrieve one |

### Disputes

| Method | Path | Purpose |
|---|---|---|
| GET  | `/v1/disputes` | List |
| GET  | `/v1/disputes/{id}` | Retrieve |
| POST | `/v1/disputes/{id}/evidence` | Submit evidence (rejected for terminal statuses) |

### Reports

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/reports?from=YYYY-MM-DD&to=YYYY-MM-DD[&merchantId=...]` | Summary + daily breakdown |

Response includes `totalVolume`, `transactionCount`, `approvedCount`,
`declinedCount`, `failedCount`, `voidedCount`, `refundCount`,
`refundVolume`, `averageApproved`, `largestApproved`, `approvalRate`,
`activeCustomers`, and `daily: [{date, volume, count, approvedCount}]`.

### Settings (merchant-wide config)

| Method | Path | Purpose |
|---|---|---|
| GET   | `/v1/settings` | Full merchant settings blob |
| PATCH | `/v1/settings` | Partial update. Also recalculates product `card_price` when `cardMarkupPercent` changes. |

### Merchants / Developers / Groups (admin + ISV)

| Method | Path | Purpose |
|---|---|---|
| GET    | `/v1/merchants` | List (role-aware) |
| POST   | `/v1/merchants` | Create (admin / ISV) |
| POST   | `/v1/merchants/onboard-isv` | Admin only — create a new ISV (developer-role user, no merchant). |
| POST   | `/v1/merchants/onboard` | Full merchant onboarding. ISV caller auto-owns. Admin caller MUST pass `owningDeveloperId` (ISV external id) — admins can't own merchants directly. |
| GET    | `/v1/merchants/{id}` | Retrieve |
| PATCH  | `/v1/merchants/{id}` | Update (admin / ISV) |
| GET    | `/v1/developers` | List developers |
| POST   | `/v1/developers` | Create (admin / ISV) |
| GET    | `/v1/developers/{id}/access` | Groups + direct merchants |
| POST   | `/v1/developers/{id}/merchants` | Grant direct merchant access |
| DELETE | `/v1/developers/{id}/merchants/{merchantId}` | Revoke |
| GET    | `/v1/developers/{id}/keys` | List API keys |
| POST   | `/v1/developers/{id}/keys` | Create API key — secret returned ONCE |
| POST   | `/v1/developers/{id}/keys/{keyId}/revoke` | Revoke |
| POST   | `/v1/developers/{id}/keys/{keyId}/rotate` | Rotate — same row, fresh secret returned ONCE. Reactivates a revoked key. |
| GET    | `/v1/merchant-groups` | List (role-aware) |
| POST   | `/v1/merchant-groups` | Create |
| GET    | `/v1/merchant-groups/{id}` | Retrieve with members + developers |
| PATCH  | `/v1/merchant-groups/{id}` | Update |
| DELETE | `/v1/merchant-groups/{id}` | Delete |
| POST   | `/v1/merchant-groups/{id}/members` | Add merchant to group |
| DELETE | `/v1/merchant-groups/{id}/members/{merchantId}` | Remove |
| POST   | `/v1/merchant-groups/{id}/developers` | Assign developer |
| DELETE | `/v1/merchant-groups/{id}/developers/{developerId}` | Unassign |

### Tokenization config

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/tokenization/config?type=card\|ach` | Returns the provider's tokenization URL + publishable key so you can build a custom hosted form |

### Audit log

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/audit-log` | Paginated. Filters: `merchantId` (admin), `eventType`, `limit`, `offset` |
| GET | `/v1/audit-log/event-types` | Distinct event_types for filter UIs |

### Jobs (admin-only, used by cron)

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/jobs/run-billing` | Runs recurring, subscriptions, and invoice auto-pay |
| POST | `/v1/jobs/run-webhook-deliveries` | Drains the webhook delivery queue |

### Terminals (POS pairing)

| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST   | `/v1/terminals/pair-codes` | dashboard JWT | Mint a short-lived pair code |
| POST   | `/v1/terminals/pair` | **none** | Redeem a pair code → terminal + device_secret |
| POST   | `/v1/terminals/realtime-token` | any | Mint a Supabase Realtime JWT scoped to the caller's merchant |
| GET    | `/v1/terminals` | any | List terminals for the current merchant |
| GET    | `/v1/terminals/{id}` | any | Retrieve |
| PATCH  | `/v1/terminals/{id}` | dashboard JWT | Rename, pin Dejavoo TPN, update config, deactivate |
| DELETE | `/v1/terminals/{id}` | dashboard JWT | Revoke (device secret stops working immediately) |
| POST   | `/v1/terminals/{id}/rotate` | dashboard JWT | Rotate — same terminal row, fresh device secret returned ONCE. Old device's secret stops working immediately. Reactivates a revoked terminal. |
| POST   | `/v1/terminals/{id}/pair-code` | dashboard JWT | Re-issue an 8-digit pair code BOUND to this terminal. When the new device redeems it via `/v1/terminals/pair`, the gateway rotates the existing terminal's device_secret instead of creating a new row. Old device keeps working until the new one pairs. |
| GET    | `/v1/terminal/bootstrap[?updated_since=ISO]` | any (usually terminal secret) | One-shot hydrate or incremental sync |
| POST   | `/v1/terminals/square/pair-code` | dashboard JWT | Mint a Square device code via `POST /v2/devices/codes`. Merchant types the 8-char code into a physical Square Terminal; the `device.code.paired` webhook stamps `terminals.square_device_id` |
| GET    | `/v1/terminals/square/{id}/status` | dashboard JWT | Poll pairing state (`paired`, `deviceId`, `status`) |
| POST   | `/v1/terminals/square/{id}/repair` | dashboard JWT | Mint a fresh code bound to an existing terminal — same id + history, new physical device |

### Square OAuth onboarding + auto-board

| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/v1/onboarding/square/start` | merchant JWT | Mints OAuth URL — production routes through ISV's reseller referral link (residuals attributed); sandbox uses plain OAuth. Embedder pops the URL, merchant grants access |
| GET  | `/v1/onboarding/square/callback` | **none** (state token is the auth) | Exchanges the OAuth code, runs 24h `created_at` age check in production, captures access_token + location_id into `merchant_providers.credentials` (or `test_credentials` in sandbox), fires `merchant.provider.connected` webhook, returns an HTML `postMessage` page back to the embedder |
| ISV setting | `isv_settings.square_auto_board` | ISV JWT | When true, every `POST /v1/merchants/onboard` returns `next_step.square_onboard_url` so the ISV's signup wizard can redirect the new merchant straight into Square OAuth |

### Messaging (phone numbers, A2P 10DLC, toll-free, voice)

See section 4.10 for end-to-end flow + sample bodies.

| Method | Path | Purpose |
|---|---|---|
| POST   | `/v1/messaging/numbers/search` | Search provider inventory |
| POST   | `/v1/messaging/numbers/buy` | Purchase a number (`409 number_already_owned`, `402 provider_billing_failed`) |
| GET    | `/v1/messaging/numbers` | List (filters: `?status`, `?campaignId`, `?provider`) |
| GET    | `/v1/messaging/numbers/{id}` | Detail (includes attached campaign + TFV ids) |
| PATCH  | `/v1/messaging/numbers/{id}` | Update inbound webhook URLs (push to provider; rolls back on failure) |
| DELETE | `/v1/messaging/numbers/{id}` | Release back to provider |
| POST   | `/v1/messaging/brands` | Register an A2P 10DLC brand. `submit:true` (default) runs upstream submission inline |
| GET    | `/v1/messaging/brands` | List |
| GET    | `/v1/messaging/brands/{id}` | Detail (with campaigns) |
| PATCH  | `/v1/messaging/brands/{id}` | Edit (only when status = `draft` or `failed`); `submit:true` resubmits |
| POST   | `/v1/messaging/brands/{id}/refresh-status` | Force-poll provider (1/min/brand) |
| POST   | `/v1/messaging/campaigns` | Register a campaign. Parent brand must be `approved`. `409 brand_not_approved` otherwise |
| GET    | `/v1/messaging/campaigns` | List |
| GET    | `/v1/messaging/campaigns/{id}` | Detail (with attached numbers) |
| PATCH  | `/v1/messaging/campaigns/{id}` | Edit (draft/failed only) |
| POST   | `/v1/messaging/campaigns/{id}/refresh-status` | Force-poll provider (1/min/campaign) |
| POST   | `/v1/messaging/campaigns/{id}/numbers` | Bulk attach numbers — campaign must be `approved`. Per-id success/failure list |
| DELETE | `/v1/messaging/campaigns/{id}/numbers/{numberId}` | Detach one |
| POST   | `/v1/messaging/tollfree-verifications` | Submit a TFV (alternative to 10DLC, for toll-free numbers only) |
| GET    | `/v1/messaging/tollfree-verifications` | List |
| GET    | `/v1/messaging/tollfree-verifications/{id}` | Detail |
| PATCH  | `/v1/messaging/tollfree-verifications/{id}` | Edit (draft/rejected/more_info_needed only) |
| POST   | `/v1/messaging/tollfree-verifications/{id}/refresh-status` | Force-poll (1/min/TFV) |
| POST   | `/v1/messaging/voice/calls` | Place an outbound call. Routes through whichever provider owns the `from` number |
| POST   | `/v1/messages/sms` | Send SMS — approval-gated when `merchant_settings.enforceMessagingApproval=true`. **402 `quota_exceeded`** when the monthly cap is hit; **402 `not_supported_in_plan`** for international destinations |
| GET    | `/v1/messaging/usage` | Current-month metering snapshot for the merchant |
| PATCH  | `/v1/messaging/settings` | Update merchant `messaging_monthly_cap_messages` (≤10k) + `messaging_alert_email` |
| GET    | `/v1/admin/messaging/tiers` | List messaging tiers (admin/ISV) |
| PATCH  | `/v1/admin/messaging/tiers/{id}` | Adjust tier rates (admin only) |
| GET    | `/v1/admin/messaging/usage?merchant_id=` | Inspect any merchant's usage (admin/ISV) |
| PATCH  | `/v1/admin/messaging/merchants/{id}/messaging-tier` | Assign a tier to a merchant (admin/ISV with access) |
| GET    | `/v1/admin/messaging/fees` | Read ISV provisioning fees + SMS overage + MMS rate (admin uses `?developer_id=`) |
| PATCH  | `/v1/admin/messaging/fees` | Upsert provisioning fees, SMS overage, MMS rate |
| GET    | `/v1/admin/messaging/tiers` | List the ISV's volume bands |
| POST   | `/v1/admin/messaging/tiers` | Add a volume band |
| PATCH  | `/v1/admin/messaging/tiers/{id}` | Update a band |
| DELETE | `/v1/admin/messaging/tiers/{id}` | Drop a band |
| PATCH  | `/v1/admin/messaging/merchants/{id}/caps` | Raise a specific merchant's monthly cap |
| GET    | `/v1/messaging/fees` | Merchant read-only view of ISV provisioning fee schedule. Now includes `tiers[]` + `current_tier_id` for the wizard's tier-picker step |
| GET    | `/v1/messaging/tiers` | Merchant-scope list of the ISV's configured subscription tiers + current selection. Same data as `/fees.tiers`; standalone for tier-picker steps |
| GET    | `/v1/messaging/upcoming-charges` | Pending fee charges that will land on the next monthly invoice |
| POST   | `/v1/messaging/brands/{id}/sync` | Retry-submit a `draft` or `failed` brand without re-entering data. Calls the provider's `registerBrand` against the persisted row |
| POST   | `/v1/messaging/brands` (`registrationTier`) | Sinch-only — `SIMPLIFIED` ($10, no vetting) vs `FULL` ($50, vetted, default). Twilio ignores |
| Field  | Brand response: `tcrBrandId` | TCR brand id (e.g. `BU2GS70`) — minted post-approval, used by downstream TCR ops |
| Field  | Brand response: `identityStatus` | TCR vetting state, separate from overall `status`. Display-only |
| Field  | Campaign response: `tcrCampaignId` | TCR campaign id, minted post-approval |
| Field  | Campaign response: `lastActionStatus` | Sinch's per-action sub-status (`CREATE_FAILED`, `RESUBMIT_FAILED`, etc.) |
| GET    | `/v1/messaging/failure-resolution?code=<code>` | Translates raw provider failure codes (TCR CR codes, Sinch operational, Twilio errors) to user-facing remediation copy with structured action steps. ISV wizards call this on the failed-state UI to render actionable fixes |
| GET    | `/v1/messaging/failure-resolution` | Returns the full dictionary, sorted by code |

---

## 8. Webhooks

### 8.1 Registering

```http
POST /v1/webhooks
{
  "url": "https://yoursite.com/hooks/tiyo",
  "events": ["payment.completed", "payment.declined", "invoice.paid", "subscription.payment_failed"]
}
```

Response returns `secret` — save it NOW. Never exposed again.

### 8.2 Verifying signatures

Every delivery includes:

- `X-Tiyo-Signature: sha256=<hex>` — HMAC-SHA256 of the raw request body
  with your signing secret. Multiple signatures (comma-separated) during a
  rotation overlap window. Accept the delivery if the body verifies
  against ANY signature in the list.
- `X-Tiyo-Event: <event.type>` — convenience header.

Node/TypeScript:

```ts
import crypto from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const computed = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = header.split(",").map(s => s.trim());
  return received.some(sig =>
    sig.length === computed.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(computed))
  );
}
```

### 8.3 Retry policy

Failed deliveries retry on a fixed schedule:

```
instant → +5m → +30m → +2h → +12h → +24h → +48h → +72h
```

After 8 total attempts the delivery is `exhausted` and the webhook
endpoint is auto-disabled. Reactivate by PATCHing `isActive: true`.

### 8.4 Event catalogue

| Type | Meaning |
|---|---|
| `payment.completed` | Charge approved |
| `payment.declined` | Charge declined |
| `payment.failed` | Gateway / processor error |
| `payment.voided` | Void succeeded |
| `payment.refunded` | Full refund |
| `payment.partial_refunded` | Partial refund |
| `subscription.created` | Customer subscribed |
| `subscription.payment_succeeded` | Cycle charge approved |
| `subscription.payment_failed` | Cycle charge failed |
| `subscription.past_due` | Hit 3 consecutive failures |
| `subscription.renewed` | Cron advanced to next period |
| `subscription.canceled` | Status flipped to cancelled |
| `invoice.sent` | Send job completed (at least one channel) |
| `invoice.paid` | Invoice settled |
| `invoice.auto_pay_approved` | Cron auto-pay approved |
| `invoice.auto_pay_declined` | Cron auto-pay declined |
| `recurring.payment_approved` | Recurring cycle approved |
| `recurring.payment_failed` | Recurring cycle failed |
| `recurring.paused` | 3 consecutive failures → paused |
| `customer.created` | New customer row |
| `webhook.test` | Synthetic test fired from dashboard |
| `messaging.number.purchased` | Number bought from the provider |
| `messaging.number.released` | Number released back |
| `messaging.brand.submitted` | Brand POSTed to provider |
| `messaging.brand.approved` | Brand verified at TCR / Sinch |
| `messaging.brand.failed` | Brand rejected (`failureReasons` in payload) |
| `messaging.campaign.submitted` | Campaign POSTed to provider |
| `messaging.campaign.approved` | Campaign approved — numbers can be attached |
| `messaging.campaign.failed` | Campaign rejected; attached numbers auto-detached |
| `messaging.campaign.numbers_attached` | Bulk-attach completed (partial success possible) |
| `messaging.campaign.number_detached` | Single-number detach |
| `messaging.tfv.submitted` | Toll-free verification submitted |
| `messaging.tfv.approved` | TFV approved; toll-free SMS now allowed |
| `messaging.tfv.rejected` | TFV rejected (`rejectionReasons` in payload) |
| `messaging.sms.sent` | SMS sent through `/v1/messages/sms` |
| `messaging.cap_warning` | Merchant crossed 80% of either messaging cap (messages or dollars) |
| `messaging.cap_reached` | Merchant hit 100% of either cap; outbound SMS now refusing with 402 |

### 8.5 Backfill via the Events API

If your webhook consumer was down, pull everything you missed:

```http
GET /v1/events?type=payment.completed&after=2026-04-20T00:00:00Z&limit=100
```

Walk with `starting_after=<last_event_id>&limit=100` until
`has_more: false`.

---

## 9. Idempotency deep dive

- `Idempotency-Key` is **required** on `POST /v1/payments`. Replaying with
  the same key within 24 h returns the cached response verbatim.
- `POST /v1/customers`, `POST /v1/invoices`, `POST /v1/customer-subscriptions`,
  `POST /v1/recurring`, `POST /v1/checkout/sessions`, `POST /v1/setup-intents`
  accept optional idempotency — pass a key if you care about replay safety,
  skip it if you don't.
- Duplicate in-flight with same key → `409 Duplicate request in progress`.
- Keep keys ≤255 chars. Use something unique: `order_{id}_{timestamp}`,
  `sub_cycle_{subscription_id}_{yyyy_mm_dd}`, `user_signup_{user_id}`.

---

## 10. Sandbox / test mode

Merchants can flip `test_mode: true` (on `merchants`). Tokenization config
and provider credentials switch to sandbox. Transactions stamped
`metadata.testMode: true` so reports can exclude them if needed.

There's no separate test URL — same endpoints, just different creds
under the hood.

---

## 11. Multi-merchant scoping (admin / ISV)

If your JWT belongs to an `admin` or an ISV (role `developer`), you can
scope any request to a specific merchant:

```http
GET /v1/products
Authorization: Bearer <jwt>
X-Merchant-Id: brz_mer_5e35...
```

Rules:
- Value accepted as either the external id (`brz_mer_...`) or the internal
  numeric id.
- If the target is not in the caller's `accessibleMerchantIds`, the
  request **403s** with `authorization_error` — the gateway never
  silently falls back to the caller's primary merchant, since that
  used to mask write-to-wrong-row bugs on per-merchant settings PATCHes.
- API keys (`brz_key_...`) are permanently bound to their merchant unless
  the owning developer is `admin`.

`GET /v1/me/accessible-merchants` tells you what the caller can scope to.

**Terminal device secrets** (`brz_term_sec_...`) are hard-bound to the
merchant they were paired under — `X-Merchant-Id` is IGNORED for terminal
callers. If a POS needs to switch merchants, re-pair.

### Auth header cheat sheet

| Caller | Header | Credential type |
|---|---|---|
| Dashboard user (browser session) | `Authorization: Bearer <Supabase JWT>` | Supabase Auth — managed by the dashboard |
| SDK / server integrator | `Authorization: Bearer <jwt>` | 15-min JWT from `POST /v1/auth/token` (client_credentials) |
| Paired POS terminal | `Authorization: Bearer brz_term_sec_...` | Long-lived device secret from `POST /v1/terminals/pair` |
| Admin / ISV acting on behalf | JWT + `X-Merchant-Id: brz_mer_...` | Switches merchant context per-request |

---

## 12. SDKs (all open-source, all follow the same shape)

- Node/TS: `npm install tiyo-sdk`
- Python: `pip install tiyo`
- PHP: `composer require tiyopay/tiyo`
- Go: `go get github.com/goodcodeworks/tiyo-gateway/sdks/go`
- Ruby: `gem install tiyo`
- Java: `com.tiyopay:tiyo:1.0.0` (Maven)
- .NET: `dotnet add package Tiyo Pay`

Every SDK is generated from the same OpenAPI spec — behavior is identical.
For languages without an official SDK, point `openapi-generator` or
Microsoft Kiota at `GET /v1/openapi.json`.

### Messaging on the Node/TS SDK

```ts
import { TiyoClient } from "tiyo-sdk";
const tiyo = new TiyoClient({ clientId, clientSecret });

// 1. Find a number to buy.
const { results } = await tiyo.messaging.numbers.search({
  numberType: "local",
  areaCode: "415",
  capabilities: ["voice", "sms"],
});

// 2. Buy it.
const num = await tiyo.messaging.numbers.buy({
  e164: results[0].e164,
  capabilities: ["voice", "sms"],
  smsInboundWebhookUrl: "https://yourapp.com/incoming-sms",
});

// 3. Register the brand (the merchant's legal entity).
const brand = await tiyo.messaging.brands.create({
  provider: "twilio",
  legalName: "Acme Self Storage LLC",
  brandName: "Acme Storage",
  taxId: "123456789",
  entityType: "PRIVATE_PROFIT",
  vertical: "REAL_ESTATE",
  street: "123 Main St", city: "Austin", region: "TX", postalCode: "78701",
  contactFirstName: "Jane", contactLastName: "Doe",
  contactEmail: "jane@acmestorage.com", contactPhone: "+15125550100",
});

// 4. Wait for approval, then register a campaign.
//    (Poll tiyo.messaging.brands.refreshStatus(brand.id) or wait for the
//    messaging.brand.approved webhook.)
const campaign = await tiyo.messaging.campaigns.create({
  brandId: brand.id,
  useCase: "account_notification",
  description: "Rent reminders + payment confirmations to opted-in tenants.",
  messageFlow: "Tenants opt in via the lease form on acmestorage.com/lease.",
  messageSamples: ["Hi {name}, rent of ${amount} is due {date}."],
  helpMessage: "Reply with questions or call +15125550100.",
});

// 5. Once the campaign is approved, attach the number.
await tiyo.messaging.campaigns.attachNumbers(campaign.id, [num.id]);

// 6. Send away.
const sent = await tiyo.messaging.sms.send({
  to: "+14155551234",
  body: "Hi Jane, your rent of $129 is due Friday. Reply STOP to opt out.",
  from: num.id,
});
console.log(sent.id, sent.from);

// Voice works the same way — `tiyo.messaging.voice.call({ from: num.id, to, twimlUrl })`.
```

---

## 13. Minimal end-to-end example (Node/TS)

```ts
import { Tiyo } from "tiyo-sdk";

const tiyo = new Tiyo({
  apiKey: process.env.TIYO_API_KEY!,
  apiSecret: process.env.TIYO_API_SECRET!,
});

// 1. Create a customer.
const cust = await tiyo.customers.create({
  first_name: "Jane",
  last_name: "Doe",
  email: "jane@example.com",
});

// 2. Mint a checkout session and redirect.
const session = await tiyo.checkout.sessions.create({
  amount: 5000,
  reference: "order-1234",
  customer_id: cust.id,
  save_card: true,
  return_url: "https://yoursite.com/thanks",
}, { idempotencyKey: "checkout-order-1234" });

console.log(session.url);

// 3. Register a webhook to hear about it.
const hook = await tiyo.webhooks.create({
  url: "https://yoursite.com/hooks/tiyo",
  events: ["payment.completed", "payment.declined"],
});
console.log("SAVE THIS NOW:", hook.secret);

// 4. Your /hooks/tiyo handler:
//    - raw-parse the body
//    - verify X-Tiyo-Signature (see §8.2)
//    - switch on req.body.type
```

---

## 14. Things that commonly trip integrators up

1. **Amounts are cents.** Sending `5.00` instead of `500` is almost
   certainly wrong. The gateway never does float math.
2. **Idempotency-Key is required on `POST /v1/payments`.** Missing → 400.
3. **`X-Merchant-Id` is silently ignored if invalid.** The API falls back
   to your primary merchant. If your list looks wrong, check the cookie /
   header.
4. **Card-present charges hold the connection open.** Don't set a tight
   HTTP timeout; SPIn terminals can take up to 180 s.
5. **`status` on a 201 is not necessarily "approved".** Check
   `response.status` — it can be `approved`, `declined`, `failed`, or
   `canceled` (CP only).
6. **Webhook secrets appear only once.** On create and on rotate.
   Persist immediately; can't retrieve later.
7. **Catalog / product `updated_at` was added recently.** If you've been
   using the gateway pre-2026-04-23, rows created before that date will
   have a synthetic `updated_at = NOW()` from the migration — a single
   full fetch is recommended before your first incremental sync.
8. **Deleting a customer is soft.** Status flips to `inactive`; active
   subscriptions cancel and active recurring billings pause. Cards stay
   on file (but idle).

---

## 15. Where to look in the source code (the gateway is open source)

- Edge function root: `supabase/functions/api/`
- `app.ts` — Hono route mounts
- `auth-middleware.ts` — JWT verify + role / access resolution
- `access.ts` — which merchants can this developer see
- `providers.ts` — EPX North, Paya, Vericheck, Dejavoo iPOSpays, SPIn,
  Linked2Pay adapters
- `service-payment.ts` — the actual charge pipeline
- `service-webhook.ts` — signature + retry schedule
- `routes-*.ts` — one file per resource family
- `schema.ts` — Drizzle ORM tables; same field names you see on the wire
  (camelCase in the DB, snake_case on the wire via formatters)

---

## 16. When in doubt

- Every response has `Tiyo-Request-Id`. Save it.
- Every list endpoint mentions its filters in its route file.
- Check `GET /v1/openapi.json` for the machine-readable source of truth.

End of document.
