Developer Documentation

Everything you need to integrate with the Tiyo Pay Gateway. Accept card payments, run subscriptions, send invoices, and receive events — all with one API key pair.

LLM Integration Guide

Single-file markdown doc covering auth, every endpoint, request/response shapes, webhooks, POS patterns, and gotchas. Paste the whole file into your code-gen LLM's context and it has everything it needs to write working integrations.

Always-latest copy is also served by the gateway at GET /v1/llm.md (public, no auth) — share that URL with consumers who only have a secret key. For code-generation LLMs that want guide + OpenAPI in one request, use GET /v1/llm.json.

Download tiyo-llm.md
API Basics
Base URL
https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api
Current API Version
2026-04-21
Auth
OAuth 2.0 client credentials → 15-min JWT
Rate Limit
200 req / 10s (300 burst) per API key

Getting Started

1. Provision an API key
Go to the merchant whose payments you want to run through Tiyo. In the dashboard: Merchants → select merchant → Users → pick the developer account → API Keys → + Create Key.

You'll get back two strings — save them both. The secret is shown exactly once.

Client ID:     brz_key_abcdef0123456789
Client Secret: brz_sec_9876543210fedcba   ← copy this now, you can't retrieve it later

Sandbox vs live: the merchant's test_mode flag controls whether charges hit the processor's sandbox or production. You use the same API key for both; flip the merchant's mode in Settings.

2. Exchange for an access token
The client-credentials grant returns a 15-minute bearer JWT. Most SDKs auto-exchange for you; these show what happens under the hood.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type":    "client_credentials",
    "client_id":     "brz_key_...",
    "client_secret": "brz_sec_..."
  }'
Response:
{
  "access_token": "eyJhbGciOi...",
  "token_type":   "Bearer",
  "expires_in":   900,
  "refresh_token": "rt_..."
}

Use the refresh_token to mint new access tokens — don't re-exchange the key pair on every request.

3. Make your first call
Include the access token on every subsequent request.
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers \
  -H "Authorization: Bearer eyJhbGciOi..."

Every response carries Tiyo-Request-Id in the headers. Save that value on your side for debugging — you can look it up in the API Logs.

Two ways to integrate

For every recurring-type resource — subscriptions, invoices, setup intents — you have a choice:

Option A — Tiyo-owned

Your app creates the resource in Tiyo (plan, subscription, invoice) and we handle the lifecycle. Our cron schedules the charges, our system tracks state, our webhooks tell you what happened.

Best when you don't already have subscription / billing logic and don't want to build it.

Option B — your-app-owned

Your app owns the schedule, the plan definitions, the invoice records. You call Tiyo only when it's time to charge a card — via /v1/payments (against a saved cardId) or /v1/checkout/sessions (for a hosted-page link). No Tiyo subscription or invoice row is created.

Best when your existing ERP / SaaS / CMS already runs this logic and you just need a processor.

You can mix and match: use Tiyo subscriptions but own your own invoices, or vice versa. Each flow below calls out which model(s) apply.

Conventions

Amounts are in cents

Every amount field is an integer. $1.00 is 100. No floats, no strings.

IDs are prefixed strings

brz_cus_... (customers), brz_sub_... (subscriptions), brz_plan_... (membership plans), brz_inv_... (invoices), brz_txn_... (payments), brz_chk_... (checkout sessions).

Timestamps are ISO-8601 UTC

2026-04-21T19:48:11.648Z

Errors are in one envelope
{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_error",
    "message": "first_name: Required",
    "request_id": "req_abc123...",
    "details": { ... }
  }
}

Branch on error.type + error.code. Don't match on message strings.

Lists are paginated
{ "data": [...], "total": 142, "page": 1, "pageSize": 50 }
Idempotency keys are supported on POST

Send an Idempotency-Key header on any write. Replay within 24h returns the same response — safe to retry on network errors.

Required on POST /v1/payments. Optional everywhere else.

Common Integration Flows

Manage customers
Every charge, subscription, and invoice can be attached to a customer record — that's how saved cards, transaction history, and subscription ownership all link together.
Create a customer
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup-42" \
  -d '{
    "first_name": "Jane",
    "last_name":  "Doe",
    "email":      "jane@example.com",
    "phone":      "+15555550123"
  }'
Response:
{
  "id":         "brz_cus_e56...",
  "first_name": "Jane",
  "last_name":  "Doe",
  "email":      "jane@example.com",
  "status":     "active",
  "createdAt":  "2026-04-22T..."
}
Attach a saved card

Use the hosted form to tokenize and attach a card to a customer without charging.

curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/checkout/sessions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "amount":      100,
    "customer_id": "brz_cus_e56...",
    "save_card":   true,
    "reference":   "card-setup-only"
  }'

After the hosted form completes, the card is attached to the customer.

curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56.../cards \
  -H "Authorization: Bearer <token>"
Response:
{
  "data": [
    { "id": 42, "last4": "4242", "brand": "visa", "exp_month": 12, "exp_year": 2028, "is_default": true }
  ]
}
Charge a saved card
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: order-7890" \
  -H "Content-Type: application/json" \
  -d '{
    "type":       "card",
    "amount":     2995,
    "customerId": "brz_cus_e56...",
    "cardId":     42,
    "reference":  "order-7890"
  }'
List / retrieve
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers?page=1&pageSize=50" -H "Authorization: Bearer <token>"
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56... -H "Authorization: Bearer <token>"
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56.../transactions -H "Authorization: Bearer <token>"
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56.../subscriptions -H "Authorization: Bearer <token>"
Update / delete
curl -X PATCH https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56... \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "email": "new@example.com" }'

curl -X DELETE https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_e56... \
  -H "Authorization: Bearer <token>"

Listen for customer.created

Charge a card — hosted
Recommended
Redirect the customer to Tiyo's hosted payment page. We handle PCI, you handle the result.
Step 1. Mint a checkout session
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/checkout/sessions \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount":     5000,
    "reference":  "order-1234",
    "return_url": "https://your.app/thanks"
  }'
Response:
{
  "id":         "brz_chk_abc...",
  "url":        "https://tiyopay.vercel.app/pay/brz_mer_...?session=brz_chk_abc...",
  "amount":     5000,
  "expires_at": "2026-04-22T20:48:11Z"
}
Step 2. Redirect the customer
Step 3. Listen for payment.completed
Charge card (embedded iframe)
Same checkout page, dropped inside your site. Customer never leaves your domain; we still handle the card data, so you stay out of PCI scope.
Step 1. Mint a checkout session server-side (same call as the redirect flow above). You get back a url.
Step 2. Drop the URL into an <iframe> on your page.
<iframe
  src="https://tiyopay.vercel.app/pay/brz_mer_...?session=brz_chk_abc..."
  width="100%"
  height="700"
  style="border:0;"
  allow="payment">
</iframe>
Step 3. Listen for the result. The hosted form posts a message to the parent window when the payment finishes.
<script>
  window.addEventListener('message', (event) => {
    if (event.data?.type === 'tiyo.payment.success') {
      // event.data.data: { transactionId, status, amount, reference, savedCard? }
      console.log('Paid:', event.data.data);
      // hide the iframe, show your own thank-you UI, etc.
    } else if (event.data?.type === 'tiyo.payment.failed') {
      // event.data.data: { error, status }
      console.warn('Payment failed:', event.data.data);
    }
  });
</script>

The return_url on the session is still honored as a fallback — if a customer somehow opens the iframe in a top-level window, they'll be redirected there after payment. For pure-embed flows you can omit it.

Already iframe-friendly: /pay/* strips X-Frame-Options and serves permissive CORS, so no allowlist setup required.

Charge a card — in-store (card-present)
In-store terminal charges via Dejavoo. Requires a paired terminal.
Prerequisite. A paired terminal registered under the merchant's Dejavoo provider.
Request. Your app calls POST /v1/payments with mode: "cp", amount, terminalId, and customerId.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: sale-7890" \
  -H "Content-Type: application/json" \
  -d '{
    "type":       "card",
    "mode":       "cp",
    "amount":     5000,
    "customerId": "brz_cus_...",
    "reference":  "sale-7890"
  }'
What happens. The terminal prompts the customer, they tap/insert/swipe, and the charge settles.
→ 201
{
  "id":              "brz_txn_...",
  "status":          "approved",     // or "declined" / "failed" / "canceled"
  "amount":          5000,
  "responseCode":    "00",
  "responseMessage": "APPROVAL",
  "reference":       "sale-7890"
}

When the terminal returns status: "canceled", the customer backed out — no charge was made.

Adding more providers. Every charge routes through your merchant's configured provider. Add or swap providers in Dashboard → Merchants → select merchant → Providers.

Void within 24h of the charge (pre-settlement) or refund afterward (post-settlement).

Voids and refunds are host-side. The terminal is touched exactly once — on the original card-present sale that mints the token. Every subsequent operation (void, refund, repeat-charge) is a host call against the processor by RRN, regardless of whether the original sale was card-present or hosted-page. The terminal is never re-contacted.

Failed voids never flip the original sale. If the processor declines or errors on the void, the response carries voidStatus: "failed" with the processor message and the original charge stays approved. The void error is preserved under provider_response.lastVoidAttempt for forensics.

Monthly subscription
Two ways to run a recurring charge — pick whichever fits your system of record.
Option A: Tiyo-managed subscription

Create a membership plan + customer subscription in Tiyo. Our cron handles billing, retries, and webhooks.

Step 1. Create a plan
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/membership-plans \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name":     "Monthly Parking",
    "price":    29500,
    "interval": "monthly"
  }'
Step 2. Collect the customer + card via a checkout session with save_card: true and a customer_id. The resulting cardId is attached to the customer.
Step 3. Create the subscription:
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customer-subscriptions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id":       "brz_cus_...",
    "plan_id":           "brz_plan_...",
    "card_id":           42,
    "next_billing_date": "2026-05-01"
  }'
Step 4. Our cron charges the customer's default card each billing cycle.
  • subscription.payment_succeeded
  • subscription.payment_failed Failed charges retry automatically (1h, 4h, 1d, 3d). After 4 fails the subscription moves to past_due.
Option B: DIY recurring (you own the cron)

Save the customer's card, then charge on your own cron.

Setup. Save the customer's card once with a setup intent, then charge on your own schedule.
Each billing cycle. Your cron decides when to charge. Call POST /v1/payments with the saved cardId.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: sub_abc123-2026-05" \
  -H "Content-Type: application/json" \
  -d '{
    "type":       "card",
    "amount":     29500,
    "customerId": "brz_cus_...",
    "cardId":     42,
    "reference":  "sub_abc123-2026-05"
  }'

No subscription row is created. Every charge stands alone.

Send invoice
Email or SMS an invoice to a customer and let them pay via hosted page.
Option A: Tiyo-managed invoice

Create the invoice in Tiyo with auto_pay: true. Our cron charges on the due date.

curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/invoices \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "brz_cus_...",
    "items": [
      { "name": "Consulting - April", "quantity": 1, "unit_price": 150000 }
    ],
    "due_date": "2026-05-15",
    "auto_pay": false
  }'

curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/invoices/{id}/send \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "channels": ["email"] }'

Set auto_pay: true on the invoice and Tiyo will charge the customer's default card on the due date.

Events: invoice.sent, invoice.paid.

Option B: Send-only invoice

Use Tiyo only to send the invoice and accept payment. Your app decides when and how much.

curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/checkout/sessions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "amount":      15000,
    "reference":   "QB-INV-8472",
    "customer_id": "brz_cus_...",
    "return_url":  "https://your.app/invoices/8472"
  }'
Response:
{ "id": "brz_chk_...", "url": "https://tiyopay.vercel.app/pay/...?session=..." }

Embed url in your own invoice email / PDF / portal. On payment.completed, mark the invoice paid in QuickBooks (or wherever). No invoices row created on our side.

Save a card (setup intent)
Tokenize a card without charging. Perfect for trials or "charge later" flows.
Step 1. Create a setup intent
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/setup-intents \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "brz_cus_...",
    "return_url":  "https://your.app/signup/done"
  }'
Response:
{
  "id":         "brz_setupintent_...",
  "url":        "https://tiyopay.vercel.app/pay/brz_mer_...?session=...",
  "purpose":    "setup",
  "status":     "pending",
  "expires_at": "..."
}
Step 2. Send the URL to the customer
Step 3. The saved card shows up under /v1/customers/{id}/cards.
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_.../cards \
  -H "Authorization: Bearer <token>"
Response:
{ "data": [ { "id": 99, "last4": "4242", "brand": "visa", "is_default": true } ] }
Step 4. Reuse the saved card
Customer portal
Mint a signed URL that lets a customer manage their own subscriptions, cards, and invoices.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_.../portal-sessions \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "return_url": "https://your.app/account",
    "expires_in": 3600
  }'
Response:
{
  "url":        "https://tiyopay.vercel.app/portal/brz_portal_...",
  "token":      "brz_portal_...",
  "expires_at": "..."
}

Redirect the customer to the returned URL. They can update cards, cancel subscriptions, pay invoices.

POS Integration

Hydrate at boot
Load the full catalog + every product in two parallel calls. Save the returned `now` timestamps as sync cursors.
# Hydrate the catalog (one call instead of seven)
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/catalog/all \
  -H "Authorization: Bearer <token>"

# Products, fully-expanded
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/products?pageSize=100&expand=department,category,group,modGroups" \
  -H "Authorization: Bearer <token>"
Incremental sync
Poll /v1/catalog/all and /v1/products with ?updated_since=<ISO> to fetch only what changed.
# Only rows changed since the cursor come back.
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/catalog/all?updated_since=2026-04-22T18:00:00Z" \
  -H "Authorization: Bearer <token>"

curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/products?updated_since=2026-04-22T18:00:00Z&expand=department,category,group,modGroups" \
  -H "Authorization: Bearer <token>"

The response's `now` field is the server's current time. Use it as your next updated_since so you never have to guess at clock drift.

Expand keys
Inline related resources on GET /v1/products and GET /v1/products/{id} so your POS renders a product picker without follow-up calls.

Pass a comma-separated list to ?expand=. Supported keys:

  • department · category · group
  • classification · supplier
  • modGroupsinlines each mod group AND the modifiers inside it

The lightweight department_name / category_name / group_name sidecars stay on by default whether or not you pass ?expand=, so the dashboard keeps working unchanged.

Terminals (POS pairing, bootstrap, realtime)

A `terminal` is a POS software instance — a register, tablet, or phone running your app. Separate from the physical Dejavoo card reader (which is already paired in the merchant's Dejavoo portal). Each terminal gets its own long-lived device secret so the merchant can revoke one register without taking the others offline, and every transaction it rings carries a `terminal_id` for attribution.

Pair a register
One-time per register. Dashboard user mints a short code; installer redeems it on the POS and saves the returned device secret.
UI shortcut: dashboard users don't have to curl this — pair + manage terminals inline at Merchants Integrate.
Step 1. Dashboard mints a code.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/terminals/pair-codes \
  -H "Authorization: Bearer <dashboard_jwt>" \
  -H "Content-Type: application/json" \
  -d '{ "suggested_name": "Register 2", "expires_in": 600 }'
# → { "code": "K7MQ-3VRX", "expires_at": "...", "expires_in": 600 }
Step 2. POS redeems the code (no auth header — the code IS the auth).
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/terminals/pair \
  -H "Content-Type: application/json" \
  -d '{
    "code": "K7MQ-3VRX",
    "name": "Register 2",
    "device_info": { "platform": "iOS 19.2", "appVersion": "1.4.0" }
  }'
# → { "terminal": {...}, "device_secret": "brz_term_sec_..." }

The device secret is returned ONCE. Save it immediately to the register's OS keychain / secure storage. If lost, revoke the terminal and re-pair — there is no recovery.

Authenticate every request
Send the device secret as a bearer token. No /v1/auth/token exchange needed — the secret is long-lived until revoked.
# Every subsequent request — no /v1/auth/token exchange needed.
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/products \
  -H "Authorization: Bearer brz_term_sec_7f3a8b1c..."

Rate limits apply per terminal (200 req / 10 s, 300 burst). Transactions created via this auth automatically carry `terminal_id` for per-register attribution.

Hydrate + incremental sync
One fat endpoint returns merchant, settings, full catalog, products, discounts, and recent customers. Re-call with ?updated_since to fetch only what changed.
# Full sync on boot
curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/terminal/bootstrap \
  -H "Authorization: Bearer brz_term_sec_..."

# Incremental — only rows changed since the cursor come back
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/terminal/bootstrap?updated_since=2026-04-23T14:00:00Z" \
  -H "Authorization: Bearer brz_term_sec_..."
# → { merchant, settings, catalog, products, discounts, customers, now, incremental }

Products are returned unbounded so the register can ring sales offline. Customers are capped at 500 most-recent and omitted from incremental responses — use /v1/customers?search= for anything older.

Realtime push (optional)
Replace the poll with a WebSocket subscription that pushes Postgres row changes the instant they happen. Uses Supabase Realtime + RLS policies scoped to your merchant_id — you cannot see other merchants' rows.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/terminals/realtime-token \
  -H "Authorization: Bearer brz_term_sec_..." \
  -H "Content-Type: application/json" \
  -d '{"expires_in": 3600}'
# → { token, expires_in, merchant_id, realtime_url, channel }

Keep a slow updated_since poll (e.g. every 5 min) running as a safety net — Realtime can drop on network blips. Tables enabled: products, discounts, merchant_settings, departments, categories, product_groups, classifications, suppliers, modifier_groups, modifiers, terminals.

Sale attribution

Every POST /v1/payments made with a terminal device secret stamps `terminal_id` on the resulting transaction. Existing API-key / dashboard traffic stays attributed to `terminal_id: null` — no migration needed.

Multi-terminal routing (Front Counter vs Drive-Thru)
A merchant with multiple physical Dejavoo countertops gets one bootstrap row per device in `paymentProviders.cardCp`. Your POS picks one, then pins each sale via `physical_terminal_id` (or `dejavoo_tpn`).
# bootstrap.paymentProviders.cardCp is an array of physical terminals
# [{ id, name, provider, mode, isActive, dejavooTpn, registerId }, ...]

curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments \
  -H "Authorization: Bearer brz_term_sec_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sale-${Date.now()}" \
  -d '{
    "type": "card",
    "mode": "cp",
    "amount": 5000,
    "physical_terminal_id": 42
  }'
# Or pass "dejavoo_tpn": "TPN111" instead — either works.

Pin is optional. Resolution order: (1) physical_terminal_id in the request, (2) dejavoo_tpn in the request, (3) terminals.dejavoo_tpn on the calling POS's paired row, (4) first active Dejavoo CP row for the merchant. Dejavoo CP is the only case allowed to have multiple active rows — every other (payment_type, mode) combo still enforces one active at a time.

Events API

Backfill missed events
Replay missed events if your webhook was down. Use cursor pagination to page through.
curl -G https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/events \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "type=payment.completed,subscription.payment_succeeded" \
  --data-urlencode "after=2026-04-21T12:00:00Z" \
  --data-urlencode "limit=100"
Response:
{
  "object": "list",
  "data": [ { "id": "brz_event_...", "type": "payment.completed", "data": {...}, "created_at": "..." } ],
  "has_more": true,
  "next_cursor": "brz_event_..."
}

Filter by type or object ID. Supports cursor pagination.

Receipts

Email or SMS a receipt
POST /v1/payments/{txnId}/receipt — singular channel. SMS messages include a link to the public hosted receipt page; emails include an inline summary plus the same link as a CTA.

SMS:

{ "channel": "sms", "phone": "+15551234567" }

Email:

{ "channel": "email", "email": "customer@example.com" }
  • channel is singular and required (defaults to "email"). Plural channels from the invoices endpoint is silently ignored.
  • email required when channel="email"; phone required when channel="sms".
  • SMS body format: <Merchant>: $X (ref Y). Receipt: <hosted-url>
  • Returns 400 if the channel's integration is disabled for this merchant (check /v1/integrations/status).
Hosted receipt page
Public, branded receipt at /r/{txnId}. Linked from every receipt SMS / email. No auth — the unguessable brz_txn_ ID is the bearer.

Backed by GET /v1/receipts/{txnId}, which returns the transaction (line items + modifiers + tip / tax / discount / tendered / change), refund/void state, and merchant branding (logo, brand color, business address, footer).

Renders a printable receipt themed by the merchant's brand color. Loud red banner + struck-through total + "Net charged" line for refunds; grey banner with line-through total for voids.

Free-form email & SMS

When to use these
For sending arbitrary messages to customers — rent reminders, appointment confirmations, ad-hoc notes. Not for transactional receipts or invoices, which have dedicated endpoints that render the right HTML and include hosted links automatically.

Both endpoints route through the merchant's resolved provider creds and respect the same gates as receipts and invoices:

  • ISV-scope (global vs merchant) determines whose creds get used.
  • Per-merchant integration_*_enabled flags gate whether the send fires at all — 400 if off.
  • SMS picks Sinch first when both Twilio and Sinch are configured. filterSmsCreds zeroes out a disabled provider so a Twilio-only merchant can't fall through to Sinch.
Send an email
POST /v1/messages/email — body has to, subject, and either html, text, or both. Optional reply_to.
{
  "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"
}
Send an SMS
POST /v1/messages/sms — body has to (E.164 preferred; bare 10-digit US normalized to +1) and body (up to 1600 chars; provider segments long messages into multipart SMS).
{
  "to": "+15551234567",
  "body": "Hi Jane, your B-12 rent of $129 is due Friday. Reply STOP to opt out."
}
Response shape
Both endpoints return the same envelope.
{ "success": true, "channel": "sms", "provider": "sinch", "id": "<upstream-message-id>" }
  • id is the upstream provider's message id — use it to look up the send at Resend / Twilio / Sinch.
  • 400 if the body is malformed or the channel is disabled for this merchant.
  • 500 if the upstream provider rejected the send (bad From number, unverified domain, etc.).

Messaging provisioning (numbers, 10DLC, toll-free)

What this is — and what it isn't
This is the surface for buying, registering, and approving the senders your messages go through. It's separate from /v1/messages/{email,sms} (which sends messages). You provision once; you send many times.
  • Phone-number lifecycle — search Twilio/Sinch inventory, buy numbers, configure inbound webhooks, release back to the provider.
  • A2P 10DLC — register a brand (merchant EIN, encrypted) and a campaign per use-case, then attach numbers. Required to send SMS to US long-codes without carrier filtering.
  • Toll-free verification — alternative path for merchants who can't go 10DLC.
  • Outbound voice — same numbers, voice calls.
  • SMS approval gatePOST /v1/messages/sms refuses to send from a long-code unless its campaign is approved, or from a toll-free unless the verification is approved.
Step-by-step — get a US long-code ready to send
The full path from "no number" to "first SMS goes out without carrier filtering."
  1. Buy a number. POST /v1/messaging/numbers/search with { areaCode, numberType: "local" } → pick one → POST /v1/messaging/numbers/buy. Status flips to active.
  2. Register a brand. POST /v1/messaging/brands with the merchant's legal name, EIN, address, contact. State machine: draft → submitted → in_review → approved/failed/suspended. EIN gets encrypted at rest via the crypto-vault. Poll POST /v1/messaging/brands/{id}/refresh-status until approved.
  3. Register a campaign. POST /v1/messaging/campaigns with use-case, sample messages, the brand id. Same state machine. Poll until approved.
  4. Attach the number. POST /v1/messaging/campaigns/{id}/numbers with { numberIds: ["brz_msgnum_..."] }. Each number can only be on one campaign at a time; switching is recorded in messaging_number_campaign_history.
  5. Send. POST /v1/messages/sms with { to, body, from: "brz_msgnum_..." }. The approval gate checks the number's attached campaign status. If approved → sent. If not → 409 messaging_not_approved.

Toll-free path is the same shape but uses POST /v1/messaging/tollfree-verifications instead of brand+campaign. The number itself stays attached to the verification record (messagingNumber.tollfreeVerificationId) once submitted.

Test mode — exercise the full flow without Twilio or Sinch

When merchants.test_mode = true, every messaging-provisioning call routes through a mock provider that:

  • Auto-approves brands and campaigns in ~5 seconds — the dashboard's status badge flips green on its own.
  • Auto-approves toll-free verifications in ~10 seconds.
  • Mints fake `brz_msgnum_…` numbers on buy, no real provider calls.
  • Logs SMS sends to the dashboard transaction list instead of hitting Twilio/Sinch.

Use this for dashboard development, integration tests, demos. End-to-end flows work without setting up a Twilio or Sinch account.

Bypassing the gate (don't, except for testing)

merchant_settings.enforce_messaging_approval defaults to true. Set it to false via PATCH /v1/settings (admin-only — merchants can't disable this themselves) and the SMS gate is bypassed.

When you'd actually want this: one-off integration testing against a real upstream when an approval is mid-review. Don't leave it off in production. Carriers (T-Mobile, AT&T, Verizon) will silently filter unregistered SMS, and the merchant won't know until customers complain that they never got the receipt.

Messaging billing & caps

Sibling to provisioning. Every dispatched and received SMS/MMS is metered against a per-merchant monthly cap; usage rolls into a line item on the merchant's next invoice.

Tiers
SlugMonthly baseIncluded SMSOverage SMSMMS
starter$0100$0.05$0.10
growth$15500$0.04$0.10
volume$402,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 on /v1/messages/sms

Before dispatch, the gateway sums the merchant's month-to-date usage against messaging_monthly_cap_messages (default 500) and messaging_monthly_cap_dollars (default $25). If either is hit:

HTTP/1.1 402 Payment Required
{
  "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 earlier with HTTP 402 not_supported_in_plan.

Metering happens after a successful dispatch — failed sends don't burn quota.

Spend alerts (80% / 100%)
  • messaging.cap_warning webhook + email at 80% of either cap
  • messaging.cap_reached webhook + email at 100% — coincides with the first 402 quota_exceeded

Each carries { reason, messages_used, messages_cap, dollars_used_cents, dollars_cap_cents, period }. Email goes to messaging_alert_email (configurable on settings) or falls back to the merchant's primary contact. messaging_alert_state has a UNIQUE (merchant_id, billing_period, threshold) constraint so each threshold fires exactly once.

Monthly invoice rollover

Wired into the existing tiyo-run-recurring-billings pg_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. Lookup is by notes LIKE '[messaging-billing:YYYY-MM]%' — the prefix is the dedupe key, since the invoices table has no separate reference column.

Inspection & admin endpoints

Merchant scope

  • GET /v1/messaging/usage — current-month { period, tier, cap, summary } snapshot. Powers the dashboard usage card.
  • GET /v1/messaging/fees — read-only view of the ISV fee schedule that applies to this merchant.
  • GET /v1/messaging/upcoming-charges — pending fee charges queued for the next monthly invoice.
  • PATCH /v1/messaging/settings — update messaging_monthly_cap_messages (≤10,000) and messaging_alert_email. Higher caps require admin/ISV.

Admin / ISV scope

  • GET /v1/admin/messaging/tiers
  • PATCH /v1/admin/messaging/tiers/{id} — admin only
  • GET /v1/admin/messaging/usage?merchant_id=brz_mer_...
  • PATCH /v1/admin/messaging/merchants/{id}/messaging-tier
  • GET /v1/admin/messaging/fees — ISV reads own; admin uses ?developer_id=
  • PATCH /v1/admin/messaging/fees — upsert ISV fee schedule
ISV-scoped fee schedule (markup over wholesale)

The platform tier 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. Configured at /isv/messaging-billing.

When fees fire

  • Brand approvedbrand_registration_fee_cents
  • Campaign approvedcampaign_registration_fee_cents
  • Toll-free verification approvedtollfree_verification_fee_cents
  • Number purchased (status=active) → number_activation_fee_cents
  • Day-1 rollover, every active numbernumber_monthly_fee_cents
  • Day-1 rollover, every approved campaigncampaign_monthly_fee_cents

Submission / approval / failure transactional emails

Every brand / campaign / TFV transition to submitted, approved, failed, or rejected fires a branded email to the merchant alongside the matching webhook event. The submitted email confirms the registration went through and sets timeline expectations (1–8 weeks for 10DLC brands, hours for campaigns under an approved brand, 2–4 weeks for TFV). Recipient is the resource's contact (brand.contactEmail / tfv.notificationEmail) with fallback to merchant_settings.business_email. Branding pulls from the merchant's logo_url + brand_color. Failure emails include the carrier's failureReasons array verbatim with a retry link.

Three per-merchant toggles on merchant_settings: notify_messaging_submissions, notify_messaging_approvals, notify_messaging_failures (all default true). PATCH any combination via /v1/settings. ISVs running their own notification flow can disable any of them to avoid double-sending.

Billing timing toggles

  • Setup fees (setup_fee_billing_mode) — next_invoice default rolls into the monthly invoice; immediate opens a one-line draft invoice the moment the underlying approval fires.
  • Tier signup (subscription_billing_mode, subscription pricing only) — arrears default bills on next monthly invoice; prepay immediately invoices the merchant for the current period when they pick a tier. Day-1 rollover dedups via UNIQUE on period_ym.

MMS gate

ISV-wide MMS toggle on isv_messaging_fees.mms_enabled (default on). When off, any POST /v1/messages/sms with non-empty mediaUrls is rejected with 403 mms_disabled. Toggled at /isv/messaging-billing → MMS rate row → "Allow MMS" checkbox. Outbound MMS dispatch isn't implemented yet — even when allowed, mediaUrls returns 501 mms_not_implemented.

Two pricing modes per ISV

isv_messaging_fees.pricing_mode picks one of:

  • Auto by volume (volume_bands) — the merchant's monthly SMS count picks the smallest tier whose ceiling covers it; that flat fee + overage above the top tier.
  • Merchant picks a tier (subscription_tiers) — merchant signs up for one tier upfront via PATCH /v1/messaging/settings { messaging_tier_id } and pays that flat fee every period regardless of usage. Overage applies above the tier's ceiling. Better margins when merchants under-utilize their plan.

Same isv_messaging_volume_bands rows serve both modes; the interpretation at rollover changes. ISV manages at /isv/messaging-billing — radio toggle for the mode, then add/edit tier rows with { up_to_messages, price_cents, name? }.

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.

Website carts — pick your stack

Each integration is its own walkthrough. Pick the one that matches the merchant's site, click in, follow the numbered steps. They all sit on the same dual-pricing data model and the same public, no-auth session-mint endpoint — start with one, switch to another later, no migration.

Dual pricing — the data model behind all three
Three columns on merchant_settings drive the pricing math everywhere a website cart renders.
enable_cash_discount = true (recommended)

The legal "cash-discount program" framing. The listed price is the card price — nothing is added on top — and ACH/cash get a discount off list. This avoids the "surcharge" classification entirely (regulated by Visa/MC and prohibited in CT, MA, ME, NY).

card = list
ach  = list × (1 − ach_discount_percent / 10000)
cash = list × (1 − cash_discount_percent / 10000)
enable_cash_discount = false

The surcharge framing — the listed price IS the cash price, card adds card_markup_percent. Don't use this in surcharge-prohibited states.

card = list × (1 + card_markup_percent / 10000)
ach  = list
cash = list

All three knobs (card_markup_percent, ach_discount_percent, cash_discount_percent) are basis points — 100 = 1.00%. Stored on merchant_settings; settable via Settings → Pricing or PATCH /v1/settings; readable without auth via GET /api/pay/config?merchantId=… so a website widget can render the math client-side.

The endpoint that ties them together
POST /api/pay/sessions on the dashboard host (NOT the gateway origin).

Public, no auth. Accepts a merchantId-only payload — Stripe-style "publishable identity," so a website widget never needs the merchant's secret key. CORS is wide-open on /api/pay/*.

POST https://tiyopay.vercel.app/api/pay/sessions
Content-Type: application/json

{
  "merchantId": "brz_mer_...",
  "amount":     2447,                         // cents — chosen-method total
  "reference":  "site-order-…",
  "line_items": [{ "name":"Box", "qty":1, "unit_price":599 }],
  "payment_methods": ["card","ach"],
  "pricing":    { "list":2497, "card":2497, "ach":2447, "achDiscount":50 },
  "metadata":   { "source":"woocommerce", "wc_order_id":"42" },
  "return_url": "https://merchant.com/order/thanks"
}

→  201
{
  "id":    "brz_chk_…",
  "url":   "https://tiyopay.vercel.app/pay/<merchantId>?session=brz_chk_…",
  "amount": 2447,
  ...
}

Whitelists forwarded fields, so the public surface can't smuggle dashboard-only knobs (save_card without a customer, skip_customer_info, etc).

Webhook envelope (what consumers receive)
Carries everything you need to match the event to your own order ids.
POST <your webhook URL>
Tiyo-Signature: t=…,v1=…

{
  "type": "payment.completed",
  "data": {
    "id":         "brz_txn_…",
    "status":     "approved",
    "amount":     2447,
    "type":       "ach",
    "reference":  "site-order-…",
    "session_id": "brz_chk_…",
    "metadata":   { "source":"woocommerce", "wc_order_id":"42" }
  }
}

The metadata the website widget passed to /api/pay/sessions is round-tripped through the session, stamped onto the resulting transaction at sale time, and surfaced on payment.completed / .declined / .failed / .refunded events. Match on metadata.your_order_id and you don't need a follow-up GET.

Compliance posture
What the merchant has to know about cash-discount programs.
  • Cash-discount program is the legally distinct framing: list price IS the card price, ACH/cash get a discount off list. Use this everywhere — Visa/MC have no restrictions on it and no states prohibit it.
  • Surcharge programs are illegal in CT, MA, ME, NY (varies) and tightly regulated in CA, FL, NJ, OK. Visa caps surcharges at 3% of the transaction. Don't toggle enable_cash_discount off for merchants in those states.
  • Disclosure at point-of-entry, the receipt, and the cart is required. The hosted page renders a "Pay by eCheck and save X%" banner automatically when ach_discount_percent > 0.

Customer portal

Two ways in
The portal at /portal/<token> is token-gated — no shared password. Two flows mint that token.
1. Merchant-minted (authenticated)

In the dashboard, the Open customer portal action on a customer's row mints a session via POST /v1/customers/{id}/portal-sessions and opens the URL in a new tab. Useful for "view as customer" or to hand a fresh URL to the customer over the phone.

2. Customer-initiated (magic link)

The customer goes to /portal/login, types their email, and we email them a one-hour sign-in link per merchant they have an account at. Endpoint: POST /v1/portal/login — public, no auth.

  • Always returns { ok: true } — no email enumeration.
  • One email per matching merchant; the customer picks which to open.
  • Sends via each merchant's resolved Resend creds (ISV scope or merchant-scope, per the integrations setup).
  • Merchants whose email integration is disabled are skipped silently.
Inside the portal
What the customer can do once they're in.
  • See and add saved cards.
  • Cancel or pause subscriptions.
  • Review past invoices and pay open ones.
  • Update contact info.

Sessions expire in 1 hour by default (capped at 24h). After expiry the URL 401s — customer reaches the login page again.

Email uniqueness
What guarantees the portal-login flow relies on.

customers.email is unique per (merchant_id, lower(email)). The same email is allowed across different merchants — that's how a person who rents at two storage facilities gets two separate sign-in links from the magic-link flow. Only collisions inside one merchant violate.

POST / PATCH /v1/customers normalize the email server-side (trim + lowercase) and return 409 email_in_use on collision instead of a generic 500.

Integrations & ISV settings

Two-tier credential model
Notification creds (Resend / Twilio / Sinch / QuickBooks) live one-per-ISV on isv_settings. Each integration has a *_scope flag.
  • scope='global' — the ISV's creds are used for every merchant they onboard. Merchants don't see the integration on their settings page.
  • scope='merchant' — each merchant brings their own creds via /v1/settings. The ISV row is ignored for that channel.

ISV manages credentials and scope at PATCH /v1/isv/settings; sensitive fields are redacted to last-4 on read, and a write whose value still starts with the redaction mask is treated as unchanged.

Per-merchant enable toggles
Independent of scope, the ISV gates each integration on or off per merchant from the merchant detail page.

Stored on merchant_settings as:

  • integration_email_enabled
  • integration_twilio_enabled + integration_sinch_enabled (independent — Twilio and Sinch are separate providers)
  • integration_quickbooks_enabled
  • integration_sms_enabled — legacy combined flag, kept for back-compat

When a flag is off the corresponding send short-circuits with a 400, regardless of whether creds are configured. A merchant allowed on Twilio but not Sinch won't accidentally fall through to Sinch — the resolver zeroes out disabled-provider creds before they reach sendSms().

Live verification
GET /v1/integrations/status runs a real auth round-trip against each provider and returns the result.

Resend hits /domains; Twilio hits /Accounts/<sid>.json; Sinch SMS hits /groups; Sinch Numbers/10DLC hits /projects/{id}/activeNumbers; QuickBooks does a refresh-token exchange. Response is one of:

  • null — not configured
  • { ok: true } — Connected (badge green)
  • { ok: false, error } — Auth failed (badge red, error in tooltip)

The dashboard's Integration page badges read directly from this — they no longer go green just because a field has a value.

Sinch reports two statuses. sinch is the SMS-batches API check (needs sinch_api_token + sinch_service_plan_id). sinch_provisioning is the Numbers/10DLC API check, which additionally needs sinch_project_id. The /v1/messaging/* provisioning endpoints gate on sinch_provisioning: a merchant can have sinch.ok = true (SMS sends work) while sinch_provisioning.ok = false (10DLC / number purchase / brand registration won't). Branch UI off the right field for the surface you're rendering.

Advanced

Expand nested objects
Use the expand query parameter to inline related resources in a single response.
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments/brz_txn_...?expand=customer" \
  -H "Authorization: Bearer <token>"
Response:
{
  "id": "brz_txn_...",
  "status": "approved",
  "amount": 5000,
  "customer": {
    "id": "brz_cus_...",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "..."
  },
  ...
}

Supported keys: customer, card, plan, invoice, subscription.

Cursor pagination
List endpoints return a nextCursor when more results exist. Pass it as the cursor query parameter to fetch the next page.
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/events?limit=50" -H "Authorization: Bearer <token>"
# → { "data": [...], "has_more": true, "next_cursor": "brz_event_last" }

curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/events?limit=50&starting_after=brz_event_last" \
  -H "Authorization: Bearer <token>"
Rotate signing secret
Swap a webhook endpoint's signing secret without downtime.
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/webhooks/{id}/rotate-secret \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "overlap_hours": 24 }'
Response:
{
  "id": "123",
  "secret":                    "whsec_NEW...",
  "previous_secret":           "whsec_OLD...",
  "previous_secret_expires_at": "2026-04-22T20:00:00Z",
  "overlap_hours": 24
}

The old secret stays valid for 24 hours so you can roll deploys.

Webhooks

Register an endpoint
curl -X POST https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/webhooks \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your.app/tiyo/webhooks",
    "events": [
      "payment.completed",
      "subscription.payment_succeeded",
      "subscription.payment_failed",
      "invoice.paid"
    ]
  }'
Response:
{
  "id": "brz_whk_...",
  "url": "...",
  "events": [...],
  "secret": "whsec_..."
}
Verify the signature
Every request carries Tiyo-Signature and Tiyo-Timestamp headers. Reject anything older than 5 minutes and compare the HMAC.
# Header looks like:
#   X-Tiyo-Signature: sha256=7e1f...
# Compute HMAC-SHA256(body, webhook_secret) and compare (constant-time)
# to the value after "sha256=". During rotation, multiple sigs arrive
# comma-separated — accept if ANY one matches a known secret.
Retry policy

Failed deliveries retry on a fixed schedule:

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

After 8 consecutive failures the endpoint is auto-disabled. Use POST /v1/webhooks/{id}/test to fire a canned event at your endpoint without triggering real state.

Event catalogue
payment.completedCharge approved
payment.declinedCharge declined
payment.failedGateway/processor error
subscription.createdCustomer subscribed
subscription.payment_succeededRecurring charge approved
subscription.payment_failedRecurring charge failed
subscription.canceledSubscription cancelled
invoice.sentInvoice sent
invoice.paidInvoice settled
customer.createdNew customer
recurring.pausedRecurring billing auto-paused after 3 failures
webhook.testTest event

API Reference

POST /v1/auth/tokenExchange API key pair for JWT
POST /v1/paymentsCharge a card — online (mode=cnp) or in-store (mode=cp)
GET /v1/paymentsList transactions
GET /v1/payments/{id}Retrieve a transaction
POST /v1/payments/{id}/refundRefund a charge (full or partial)
POST /v1/payments/{id}/voidVoid pre-settlement
POST /v1/payments/{id}/receiptEmail or SMS a receipt (channel: 'email'|'sms', requires email or phone)
GET /v1/receipts/{id}Public hosted-receipt JSON. No auth — txn external ID is the bearer.
GET /v1/integrations/statusLive verification per integration (Resend/Twilio/Sinch/QuickBooks)
GET /v1/isv/settingsRead ISV-level integration credentials and per-channel scope
PATCH /v1/isv/settingsUpdate ISV-level integration credentials and scope
GET /v1/settingsPer-merchant settings — pricing, accepted methods, integration enable toggles
PATCH /v1/settingsUpdate per-merchant settings (admin can use X-Merchant-Id to target a specific merchant)
GET /v1/customersList customers
POST /v1/customersCreate a customer
GET /v1/customers/{id}Retrieve a customer
PATCH /v1/customers/{id}Update a customer
GET /v1/customers/{id}/cardsList saved cards for a customer
POST /v1/membership-plansCreate a plan
GET /v1/membership-plansList plans
POST /v1/customer-subscriptionsSubscribe a customer to a plan
GET /v1/customer-subscriptionsList subscriptions
POST /v1/invoicesCreate an invoice
POST /v1/invoices/{id}/sendSend invoice via email / SMS
POST /v1/checkout/sessionsMint a hosted-payment-page session
POST /v1/setup-intentsSave a card without charging
GET /v1/setup-intents/{id}Retrieve a setup intent
POST /v1/customers/{id}/portal-sessionsMint a customer-portal URL
POST /v1/portal/loginPublic — customer enters email, gets a magic-link sign-in email per merchant
POST /v1/merchants/onboard-isvAdmin only — create a new ISV (developer-role user, no merchant).
POST /v1/merchants/onboardFull merchant onboarding. Admin must pass owningDeveloperId; ISV auto-owns.
POST /v1/messages/emailSend a free-form email through the merchant's Resend creds. Honors ISV scope + integration_email_enabled.
POST /v1/messages/smsSend a free-form SMS through the merchant's Twilio or Sinch creds. Approval-gated when enforce_messaging_approval=true.
POST /v1/messaging/numbers/searchSearch Twilio/Sinch inventory for available numbers.
POST /v1/messaging/numbers/buyPurchase a number. Returns the new messaging_numbers row.
GET /v1/messaging/numbersList the merchant's owned numbers. Filters: ?status, ?campaignId, ?provider.
GET /v1/messaging/numbers/{id}Detail.
PATCH /v1/messaging/numbers/{id}Update inbound SMS / voice webhook URLs.
DELETE /v1/messaging/numbers/{id}Release back to the provider.
POST /v1/messaging/brandsRegister an A2P 10DLC brand (merchant EIN encrypted at rest).
POST /v1/messaging/brands/{id}/refresh-statusRe-poll the upstream provider for the brand's review state.
GET /v1/messaging/brandsList + GET /v1/messaging/brands/{id} detail; PATCH for editable fields.
POST /v1/messaging/campaignsRegister a campaign under a brand. Returns 201 with the new row.
POST /v1/messaging/campaigns/{id}/numbersAttach numbers to the campaign. Each number can only be on one campaign at a time.
DELETE /v1/messaging/campaigns/{id}/numbers/{numberId}Detach. The history row stays for audit.
POST /v1/messaging/campaigns/{id}/refresh-statusRe-poll the campaign's review state.
POST /v1/messaging/tollfree-verificationsSubmit a toll-free verification (alternative to 10DLC).
POST /v1/messaging/voice/callsOutbound voice call through the merchant's Twilio/Sinch.
GET /v1/messaging/usageCurrent-month metering snapshot — { period, tier, cap, summary }. Powers the dashboard usage card.
PATCH /v1/messaging/settingsUpdate merchant messaging_monthly_cap_messages (≤10k) and messaging_alert_email.
POST /v1/customer-subscriptions (billing_anchor)Optional body field { mode: anniversary | calendar_day, day: 1-28, first_cycle: prorate | advance | full, grace_days } — calendar_day mode anchors every cycle to a fixed day-of-month for storage/HOA-style billing where everyone pays on the 1st. Falls back to merchant_settings.default_billing_anchor_* (configured on /settings) when omitted.
PATCH /v1/admin/merchants/{id}/saas-billingUpsert a merchant's monthly SaaS subscription on the ISV's platform-customer record (amount_cents=0 cancels).
POST /v1/admin/merchants/{id}/saas-chargeAd-hoc setup-fee charge — creates an auto-pay invoice that pulls from the merchant's saved card on the next hourly cron pass.
POST /v1/admin/merchants/{id}/saas-portal-linkMint a 1-hour card-on-file portal link the ISV emails to the merchant.
POST /v1/payments/tokenizeMint a reusable card token without holding funds. Internally runs $0 Auth-Only with $1 auth + reversal fallback — no captured sale, no settlement, chdToken stays valid. Replaces the prior $1 sale + auto-void path.
POST /v1/admin/cards/revalidateSweep saved tokens through provider.tokenize(); mark rejects with invalidated_at. Auto-pay skips invalidated cards and emits payment_method.invalid. Use after a tokenize-flow incident to identify customers who need to re-add their card.
POST /v1/admin/cards/test-mintAdmin/developer + test_mode merchants only. 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. Body: { customerId, merchantId?, brand, exp_month, exp_year, last4?, isDefault? }.
POST /v1/admin/cards/test-mint-bulkBulk variant — { merchantId, customerIds: [...], brand, exp_month, exp_year, isDefault? }. Returns { minted, failed, cards, failures }.
POST /v1/customer-subscriptions (bundle_key)Opt-in bundling — subs with the same (customer_id, bundle_key, card_id, next_billing_date) charge as ONE transaction with a multi-line invoice. Use a stable tag like 'tenant:42' or 'memberships'. One invoice.paid event carries metadata.subscription_ids: [...]. Solo subs (no bundle_key) keep firing independently.
POST /v1/payments/{id}/refund (subscription_id)For bundled charges — pass subscription_id to refund just that line. Looks up invoice_items.total for the sub on the bundled invoice.
POST /v1/onboarding/square/startMints a Square OAuth onboarding URL — production uses the ISV's reseller referral link (residual-attributed), sandbox uses plain OAuth. Embedder pops it; success postMessage's back.
GET /v1/onboarding/square/callbackPublic Square OAuth callback (state token is the auth). Exchanges the code, runs new-account 24h check in production, persists tokens in merchant_providers, fires merchant.provider.connected.
GET /v1/admin/messaging/usage?merchant_id=Inspect any merchant's current-month usage (admin/ISV).
PATCH /v1/admin/messaging/merchants/{id}/messaging-tierAssign a tier to a merchant (admin/ISV with access).
GET /v1/admin/messaging/feesISV reads own provisioning fee schedule; admin targets any ISV via ?developer_id=.
PATCH /v1/admin/messaging/feesUpsert ISV provisioning fee schedule (one-time fees + monthly recurring).
GET /v1/admin/messaging/tiersList the ISV's volume bands.
POST /v1/admin/messaging/tiersAdd a band: { up_to_messages, price_cents }.
PATCH /v1/admin/messaging/tiers/{id}Update a volume band (owner-checked).
DELETE /v1/admin/messaging/tiers/{id}Drop a volume band.
PATCH /v1/admin/messaging/merchants/{id}/capsRaise/lower a specific merchant's monthly cap above the 10k self-service ceiling.
GET /v1/messaging/feesMerchant read-only view of the ISV fee schedule that applies to them.
GET /v1/messaging/upcoming-chargesPending fee charges that will land on the merchant's next monthly invoice.
GET /v1/eventsHistorical event log (cursor paginated)
GET /v1/events/{id}Retrieve a single event
POST /v1/webhooksRegister a webhook endpoint
POST /v1/webhooks/{id}/testFire a synthetic event
POST /v1/webhooks/{id}/rotate-secretRotate signing secret (overlap)
GET /v1/reportsAggregated sales / txn counts
GET /v1/productsList products (supports expand, updated_since)
GET /v1/products/{id}Retrieve a product (supports expand)
GET /v1/catalog/allHydrate every catalog entity in one call
GET /v1/catalog?entity=List one catalog entity type at a time
POST /v1/terminals/pair-codesMint a short-lived pair code (dashboard)
POST /v1/terminals/pairRedeem a pair code → device secret (no auth)
POST /v1/terminals/realtime-tokenMint a Supabase Realtime JWT scoped to the caller's merchant
GET /v1/terminalsList terminals for the current merchant
PATCH /v1/terminals/{id}Rename / pin TPN / update config / deactivate
DELETE /v1/terminals/{id}Revoke a terminal (device secret stops working)
GET /v1/terminal/bootstrapOne-shot hydrate or incremental sync (?updated_since)
Download full OpenAPI 3.1 spec

Import into Postman, Insomnia, or your code generator of choice.

Official SDKs

Seven official SDKs, every one covers the same resource surface (customers, payments, subscriptions, plans, invoices, checkout, setup intents, webhooks, events, portal). Pick yours:

LanguageInstallMinimum
Node / TypeScriptnpm install tiyo-sdkNode 18+
Pythonpip install tiyoPython 3.8+
PHPcomposer require tiyopay/tiyoPHP 7.4+
Gogo get github.com/goodcodeworks/tiyo-gateway/sdks/goGo 1.21+
Rubygem install tiyoRuby 2.7+
Javacom.tiyopay:tiyo:1.0.0 (Maven)Java 11+
.NET (C#)dotnet add package Tiyo Pay.NET 6+

Every SDK reads the same OpenAPI spec at /v1/openapi.json, so behavior is identical across languages.

Don't see your stack? Use the OpenAPI spec with openapi-generator or Microsoft Kiota to produce a stub in any supported language.

Python
from tiyo import Tiyo
tiyo = Tiyo(api_key="brz_key_...", api_secret="brz_sec_...")

cust = tiyo.customers.create(
    first_name="Jane", last_name="Doe",
    email="jane@example.com",
)
pay = tiyo.payments.create(
    type="card", amount=5000, cardId=42,
    customerId=cust["id"],
    idempotency_key="order-1234",
)
PHP
use Tiyo\Tiyo;
$tiyo = new Tiyo('brz_key_...', 'brz_sec_...');

$cust = $tiyo->customers()->create([
    'first_name' => 'Jane', 'last_name' => 'Doe',
    'email' => 'jane@example.com',
]);
$pay = $tiyo->payments()->create([
    'type' => 'card', 'amount' => 5000,
    'cardId' => 42, 'customerId' => $cust['id'],
], 'order-1234');
Go
client, _ := tiyo.New(tiyo.Config{
    APIKey: "brz_key_...", APISecret: "brz_sec_...",
})
cust, _ := client.Customers.Create(ctx, tiyo.Params{
    "first_name": "Jane", "last_name": "Doe",
    "email": "jane@example.com",
}, nil)
pay, _ := client.Payments.Create(ctx, tiyo.Params{
    "type": "card", "amount": 5000, "cardId": 42,
    "customerId": cust["id"],
}, &tiyo.RequestOptions{IdempotencyKey: "order-1234"})
Ruby
require "tiyo"
tiyo = Tiyo::Client.new(
  api_key: "brz_key_...",
  api_secret: "brz_sec_...",
)

cust = tiyo.customers.create(
  first_name: "Jane", last_name: "Doe",
  email: "jane@example.com",
)
pay = tiyo.payments.create(
  { type: "card", amount: 5000, cardId: 42,
    customerId: cust["id"] },
  idempotency_key: "order-1234",
)
Java
import com.tiyopay.Tiyo;
import java.util.Map;

Tiyo tiyo = Tiyo.newBuilder(
    "brz_key_...", "brz_sec_...").build();

Map<String, Object> cust = tiyo.customers.create(Map.of(
    "first_name", "Jane", "last_name", "Doe",
    "email", "jane@example.com"), null);

Map<String, Object> pay = tiyo.payments.create(Map.of(
    "type", "card", "amount", 5000,
    "cardId", 42, "customerId", cust.get("id")),
    new Tiyo.RequestOptions().idempotencyKey("order-1234"));
.NET (C#)
using Tiyo Pay;
var tiyo = new Tiyo("brz_key_...", "brz_sec_...");

var cust = await tiyo.Customers.CreateAsync(new {
    first_name = "Jane", last_name = "Doe",
    email = "jane@example.com"
});
var pay = await tiyo.Payments.CreateAsync(new {
    type = "card", amount = 5000, cardId = 42,
    customerId = cust["id"]
}, idempotencyKey: "order-1234");

Tools

Stuck?

Save the Tiyo-Request-Id header from the failing response. It's the fastest way for us to root-cause any issue — every request is logged server-side for 7 days.