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.
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.
Get an API key and make your first call in under 5 minutes.
Create, save cards, charge, list transactions.
One-off charges, subscriptions, invoices.
Hydrate your POS with one call and stay in sync.
Email/SMS receipts + the public hosted receipt page at /r/{txnId}.
ISV-level credentials, per-channel scope, per-merchant enable toggles, live verification.
Token-gated self-service. Merchant-minted links + customer-initiated magic-link sign-in.
Free-form sends through the merchant's Resend / Twilio / Sinch creds. /v1/messages.
Buy phone numbers, register 10DLC brand + campaign, submit toll-free verification. /v1/messaging.
Tiers, monthly cap enforcement, 80%/100% spend alerts, monthly invoice rollover. /v1/messaging/usage.
Drop-in <script> widget, WooCommerce plugin, hosted storefront. Dual-pricing built in.
Every endpoint with request/response shapes.
https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api2026-04-21Getting Started
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 laterSandbox 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.
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_..."
}'{
"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.
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:
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.
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
Every amount field is an integer. $1.00 is 100. No floats, no strings.
brz_cus_... (customers), brz_sub_... (subscriptions), brz_plan_... (membership plans), brz_inv_... (invoices), brz_txn_... (payments), brz_chk_... (checkout sessions).
2026-04-21T19:48:11.648Z
{
"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.
{ "data": [...], "total": 142, "page": 1, "pageSize": 50 }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
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"
}'{
"id": "brz_cus_e56...",
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"status": "active",
"createdAt": "2026-04-22T..."
}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>"{
"data": [
{ "id": 42, "last4": "4242", "brand": "visa", "exp_month": 12, "exp_year": 2028, "is_default": true }
]
}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"
}'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>"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
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"
}'{
"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"
}url.<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><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.
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"
}'→ 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.
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.
Create a membership plan + customer subscription in Tiyo. Our cron handles billing, retries, and webhooks.
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"
}'save_card: true and a customer_id. The resulting cardId is attached to the customer.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"
}'subscription.payment_succeededsubscription.payment_failedFailed charges retry automatically (1h, 4h, 1d, 3d). After 4 fails the subscription moves to past_due.
Save the customer's card, then charge on your own cron.
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.
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.
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"
}'{ "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.
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"
}'{
"id": "brz_setupintent_...",
"url": "https://tiyopay.vercel.app/pay/brz_mer_...?session=...",
"purpose": "setup",
"status": "pending",
"expires_at": "..."
}curl https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/customers/brz_cus_.../cards \
-H "Authorization: Bearer <token>"{ "data": [ { "id": 99, "last4": "4242", "brand": "visa", "is_default": true } ] }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
}'{
"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 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>"# 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.
Pass a comma-separated list to ?expand=. Supported keys:
department·category·groupclassification·suppliermodGroups— inlines 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.
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 }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.
# 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.
# 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.
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.
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.
# 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
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"{
"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
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"). Pluralchannelsfrom the invoices endpoint is silently ignored. - email required when
channel="email"; phone required whenchannel="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).
/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
Both endpoints route through the merchant's resolved provider creds and respect the same gates as receipts and invoices:
- ISV-scope (
globalvsmerchant) determines whose creds get used. - Per-merchant
integration_*_enabledflags gate whether the send fires at all — 400 if off. - SMS picks Sinch first when both Twilio and Sinch are configured.
filterSmsCredszeroes out a disabled provider so a Twilio-only merchant can't fall through to Sinch.
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"
}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."
}{ "success": true, "channel": "sms", "provider": "sinch", "id": "<upstream-message-id>" }idis 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)
/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 gate —
POST /v1/messages/smsrefuses to send from a long-code unless its campaign isapproved, or from a toll-free unless the verification isapproved.
- Buy a number.
POST /v1/messaging/numbers/searchwith{ areaCode, numberType: "local" }→ pick one →POST /v1/messaging/numbers/buy. Status flips toactive. - Register a brand.
POST /v1/messaging/brandswith 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. PollPOST /v1/messaging/brands/{id}/refresh-statusuntilapproved. - Register a campaign.
POST /v1/messaging/campaignswith use-case, sample messages, the brand id. Same state machine. Poll untilapproved. - Attach the number.
POST /v1/messaging/campaigns/{id}/numberswith{ numberIds: ["brz_msgnum_..."] }. Each number can only be on one campaign at a time; switching is recorded inmessaging_number_campaign_history. - Send.
POST /v1/messages/smswith{ 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.
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.
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.
| 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}.
/v1/messages/smsBefore 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.
messaging.cap_warningwebhook + email at 80% of either capmessaging.cap_reachedwebhook + email at 100% — coincides with the first402 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.
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.
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— updatemessaging_monthly_cap_messages(≤10,000) andmessaging_alert_email. Higher caps require admin/ISV.
Admin / ISV scope
GET /v1/admin/messaging/tiersPATCH /v1/admin/messaging/tiers/{id}— admin onlyGET /v1/admin/messaging/usage?merchant_id=brz_mer_...PATCH /v1/admin/messaging/merchants/{id}/messaging-tierGET /v1/admin/messaging/fees— ISV reads own; admin uses?developer_id=PATCH /v1/admin/messaging/fees— upsert ISV fee schedule
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 approved →
brand_registration_fee_cents - Campaign approved →
campaign_registration_fee_cents - Toll-free verification approved →
tollfree_verification_fee_cents - Number purchased (status=active) →
number_activation_fee_cents - Day-1 rollover, every active number →
number_monthly_fee_cents - Day-1 rollover, every approved campaign →
campaign_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_invoicedefault rolls into the monthly invoice;immediateopens a one-line draft invoice the moment the underlying approval fires. - Tier signup (
subscription_billing_mode, subscription pricing only) —arrearsdefault bills on next monthly invoice;prepayimmediately 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 viaPATCH /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.
<script> widgetcart.liquid. Tiyo takes payment; Shopify keeps the cart and the order. Reconcile by cart token./shop/<merchantId> from the merchant's product catalog. For merchants without a website.merchant_settings drive the pricing math everywhere a website cart renders.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)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 = listAll 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.
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).
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.
- 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_discountoff 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
/portal/<token> is token-gated — no shared password. Two flows mint that token.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.
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.
- 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.
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
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.
Stored on merchant_settings as:
integration_email_enabledintegration_twilio_enabled+integration_sinch_enabled(independent — Twilio and Sinch are separate providers)integration_quickbooks_enabledintegration_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().
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
curl "https://crwbtzryadlpmlupgcmz.supabase.co/functions/v1/api/v1/payments/brz_txn_...?expand=customer" \
-H "Authorization: Bearer <token>"{
"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.
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>"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 }'{
"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
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"
]
}'{
"id": "brz_whk_...",
"url": "...",
"events": [...],
"secret": "whsec_..."
}# 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.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.
payment.completedCharge approvedpayment.declinedCharge declinedpayment.failedGateway/processor errorsubscription.createdCustomer subscribedsubscription.payment_succeededRecurring charge approvedsubscription.payment_failedRecurring charge failedsubscription.canceledSubscription cancelledinvoice.sentInvoice sentinvoice.paidInvoice settledcustomer.createdNew customerrecurring.pausedRecurring billing auto-paused after 3 failureswebhook.testTest eventAPI Reference
POST /v1/auth/tokenExchange API key pair for JWTPOST /v1/paymentsCharge a card — online (mode=cnp) or in-store (mode=cp)GET /v1/paymentsList transactionsGET /v1/payments/{id}Retrieve a transactionPOST /v1/payments/{id}/refundRefund a charge (full or partial)POST /v1/payments/{id}/voidVoid pre-settlementPOST /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 scopePATCH /v1/isv/settingsUpdate ISV-level integration credentials and scopeGET /v1/settingsPer-merchant settings — pricing, accepted methods, integration enable togglesPATCH /v1/settingsUpdate per-merchant settings (admin can use X-Merchant-Id to target a specific merchant)GET /v1/customersList customersPOST /v1/customersCreate a customerGET /v1/customers/{id}Retrieve a customerPATCH /v1/customers/{id}Update a customerGET /v1/customers/{id}/cardsList saved cards for a customerPOST /v1/membership-plansCreate a planGET /v1/membership-plansList plansPOST /v1/customer-subscriptionsSubscribe a customer to a planGET /v1/customer-subscriptionsList subscriptionsPOST /v1/invoicesCreate an invoicePOST /v1/invoices/{id}/sendSend invoice via email / SMSPOST /v1/checkout/sessionsMint a hosted-payment-page sessionPOST /v1/setup-intentsSave a card without chargingGET /v1/setup-intents/{id}Retrieve a setup intentPOST /v1/customers/{id}/portal-sessionsMint a customer-portal URLPOST /v1/portal/loginPublic — customer enters email, gets a magic-link sign-in email per merchantPOST /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 eventPOST /v1/webhooksRegister a webhook endpointPOST /v1/webhooks/{id}/testFire a synthetic eventPOST /v1/webhooks/{id}/rotate-secretRotate signing secret (overlap)GET /v1/reportsAggregated sales / txn countsGET /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 callGET /v1/catalog?entity=List one catalog entity type at a timePOST /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 merchantGET /v1/terminalsList terminals for the current merchantPATCH /v1/terminals/{id}Rename / pin TPN / update config / deactivateDELETE /v1/terminals/{id}Revoke a terminal (device secret stops working)GET /v1/terminal/bootstrapOne-shot hydrate or incremental sync (?updated_since)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:
| Language | Install | Minimum |
|---|---|---|
| Node / TypeScript | npm install tiyo-sdk | Node 18+ |
| Python | pip install tiyo | Python 3.8+ |
| PHP | composer require tiyopay/tiyo | PHP 7.4+ |
| Go | go get github.com/goodcodeworks/tiyo-gateway/sdks/go | Go 1.21+ |
| Ruby | gem install tiyo | Ruby 2.7+ |
| Java | com.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.
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",
)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');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"})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",
)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"));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
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.