Developers
Onfire Platform Integration Guide
This guide is for Platforms — third-party software that hosts many independent practitioners and integrates with Onfire on each practitioner's behalf to list their rate cards, create invoices or Checkout Sessions, and receive lifecycle events. It covers authentication, the rate-card, invoice, and Checkout Session APIs, and the webhook contract, with end-to-end examples.
A reference implementation that exercises this entire flow (multi-practitioner OAuth, invoice creation, signed webhook receiver) is available at https://github.com/onfire-health/platform-demo.
Overview
The integration has five stages, covered in detail below:
- Onboarding — get your
client_id/client_secretfrom Onfire; the URLs and scopes you'll need are already documented (§3). - Connect a practitioner — one-time OAuth consent per practitioner; store the resulting access + refresh tokens (§4).
- Resolve the practitioner — call
/meta/partners/meonce per connection to get theirpartner_public_id(§5). - List rate cards & charge — show offerings, then create an invoice or a Checkout Session; the guest pays on an Onfire-hosted page (§6, §6.4).
- Register your endpoint, then receive & verify webhooks — self-service from your partner account; verify the signature over the raw request body (§7).
1. Concepts
| Term | Meaning |
|---|---|
| Platform | Your application. You hold one OAuth client (Connected App) and connect many practitioners through it. |
| Connection | One practitioner's authorization of your Platform, established once via OAuth consent. You hold N connections (one access/refresh token pair per practitioner). |
| Practitioner | The Onfire customer you act for. Each is identified to you by a stable partner_public_id (prefix prt_…) — use it to route webhook events to the right practitioner in your system. |
| Rate card | A practitioner's purchasable offering. Referenced by an opaque ref_id; you pass ref_id when creating an invoice or a Checkout Session. The price is set by the practitioner, never by you. |
| Invoice | A charge you create for a practitioner's client (the guest). Identified by your own external_invoice_ref (your idempotency key) and Onfire's invoice_public_id. |
| Checkout Session | A hosted payment page for one charge, created when the payer is ready. Identified by your client_reference_id and Onfire's public_id (cs_…). Send the payer to payer_url. |
| Guest | The practitioner's customer who pays. They pay on an Onfire-hosted page (pay_url for an invoice, payer_url for a Checkout Session); you never handle card/bank data. |
PHI-minimal contract: outbound webhooks carry only money/status + your correlation ids (partner_public_id, external_invoice_ref, external_client_ref, and for a Checkout Session client_reference_id plus the metadata you authored) — never the guest's name, email, phone, or address.
2. Environments
Each environment involves three distinct hosts — the API base (used as $BASE in the examples below), the Authorize URL (where you send the practitioner to consent), and the Token URL (Stytch's, not Onfire's — see §4). They are not interchangeable — using the API base URL where the Token URL belongs (or vice versa) is a common integration mistake we see, and it typically shows up as a 404.
Staging
| Purpose | URL |
|---|---|
| API base | https://api-stage.onfirehealth.com |
| Authorize URL | https://staging-partner.onfirehealth.com/oauth/authorize |
| Token URL | https://login-staging-partner.onfirehealth.com/v1/public/project-test-51833b68-1bab-4bf9-b670-544c5785af15/oauth2/token |
Production
| Purpose | URL |
|---|---|
| API base | https://api.onfirehealth.com |
| Authorize URL | https://partner.onfirehealth.com/oauth/authorize |
| Token URL | https://login-partner.onfirehealth.com/v1/public/project-live-4f6f7856-c03b-4ea7-b0bd-2a479a0b01f4/oauth2/token |
All API endpoints in §6 and §7 are relative to the API base URL only. All traffic is HTTPS only.
3. Platform onboarding
Contact us at partner@onfirehealth.com. You will need to give us the redirect URI you intend to use. It can be updated later. Onfire registers your Connected App and gives you aclient_id and client_secret — your OAuth client credentials (store securely). This is per environment.
4. Practitioner authentication (OAuth 2.0)
Onfire uses the standard authorization_code grant via Stytch Connected Apps. Each practitioner consents once; you store the resulting tokens and call the API with the practitioner's access token.
Onfire is purely a resource server. It only validates the access token — the Connected Apps JWKS signature, the audience, the expiry, and the
payment-connectionscope — and never issues, refreshes, or stores platform tokens. The entire token lifecycle (authorize, exchange, refresh) is between your platform and Stytch; Onfire is not in that loop.
Note. The token endpoint is a separate host from your API base URL — see the Token URL row for your environment in §2. You cannot call it until you complete step 1 below and receive a
code— calling it first (e.g. with just yourclient_id/client_secretand no code) returnsunauthorized_client.
4.1 Connect a practitioner (one-time consent)
This is the actionable sequence:
- Generate a random, unguessable
statevalue and persist it server-side against whatever record represents "this practitioner is mid-connection" in your system — you'll validate it when the browser returns in step 3. - Redirect the practitioner to the authorize URL (§2) with your
client_id,redirect_uri,response_type=code, thatstate, and the requestedscope=payment-connection offline_access. Both scopes are required:payment-connectiongrants access to the partner APIs, andoffline_accessis what makes Stytch return a refresh token.
For example, against staging with dummy values:
The authorize URL requires the practitioner to already be logged into their own Onfire account. If they are not, it redirects to login and then back here, and then renders the consent widget. There is no payout or bank-account gate on this page: a practitioner who has not finished payout setup can still connect your platform. Payout readiness is the practitioner's concern and does not affect the connection or your token.# Staging — see §2 for the Authorize URL per environment https://staging-partner.onfirehealth.com/oauth/authorize?response_type=code&client_id=connected-app-test-12345678-4c3d-4e57&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&scope=payment-connection%20offline_access&state=123456-unguessable-sharedirect_urion this URL is informational. The value that actually sends the practitioner back to you is the one registered against yourclient_id. Keep the query-string value identical to the registered one, and do not treat the query string as your security boundary. - On approval: Onfire redirects back to your registered
redirect_uriwith?code=…&state=….
On denial — the practitioner declines consent — you instead receive?error=access_denied&error_description=…&state=…(RFC 6749 §4.1.2.1-style).
Either way: verify the returnedstatematches the one you generated in step 1 and haven't already consumed — this is your CSRF protection — and discard/reject it if it doesn't. Iferroris present, stop; do not attempt a token exchange. - Exchange the
codeat Stytch's token endpoint — the Token URL for your environment, from §2 — using HTTP Basic auth withclient_id:client_secret:
# STYTCH_TOKEN_URL = the Token URL for your environment — see §2
curl -X POST "$STYTCH_TOKEN_URL" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="<code>" \
-d redirect_uri="https://platform.example.com/api/oauth/callback"
# → { "access_token": "...", "refresh_token": "...", "expires_in": 3600, ... }redirect_uri in the exchange must match the one on the authorize request. Store the access_token + refresh_token per practitioner. The access token is a JWT scoped to exactly that practitioner — it can never act for another.
4.2 Refresh
When the access token nears or reaches expiry, exchange the refresh token at the same Stytch token endpoint (grant_type=refresh_token) for a new access token. Onfire is not involved.
# Same Stytch token endpoint; Onfire is not involved.
curl -X POST "$STYTCH_TOKEN_URL" -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token -d refresh_token="<refresh_token>"4.3 Access token TTL
The access token is issued and timed by Stytch, its lifetime is surfaced to you as expires_in in the token-exchange response and as the exp claim in the JWT. The current lifetime is 3600 seconds (60 minutes).
Read expires_in programmatically rather than hardcoding 3600. Refresh proactively shortly before it elapses; because there is no clock-skew leeway in validation, refresh slightly ahead of exp rather than exactly at it. Also refresh reactively on a 401 (see §9).
4.4 Calling the API
Send the practitioner's access token as a bearer token:
Authorization: Bearer <access_token>5. Resolve the practitioner — GET /api/v1/meta/partners/me
The access token is a JWT. Either decode it for a partner_public_id claim, or call /me once per connection (for example right after consent). partner_public_id is the stable key Onfire uses to attribute invoices, Checkout Sessions, and webhooks to this practitioner — key your connection record on it, not on an organization id alone.
curl "$BASE/api/v1/meta/partners/me" -H "Authorization: Bearer $ACCESS_TOKEN"{
"partner_id": 42,
"partner_public_id": "prt_9f1c3a7b8d2e4f60a1b2c3d4e5f60718",
"partner_name": "The Happy Health Co",
"bill_com_vendor_status": "VERIFIED"
}partner_public_id is the routing key: every webhook you receive carries it, so this mapping is how you attribute events to the right practitioner. bill_com_vendor_status reports payout readiness only. It does not gate the connection.
6. APIs
6.1 List rate cards — GET /api/v1/meta/partner-rate-cards/
Returns the calling practitioner's active rate cards (no cross-tenant data). Optional query params: payout_plan, search, sort_by, skip, limit.
curl "$BASE/api/v1/meta/partner-rate-cards/" -H "Authorization: Bearer $ACCESS_TOKEN"[
{
"rate_card_id": 1201,
"partner_id": 42,
"company": "The Happy Health Co",
"product_name": "Initial Consultation",
"ref_id": "rc_7c2a91e4",
"sub_title": "60-minute intake",
"type": "bundle",
"duration": "6", // installment months
"full_price": "2050.00",
"installments_price": "1999.50",
"full_price_only": false,
"created_at": "2026-06-01T12:00:00Z",
"updated_at": "2026-06-01T12:00:00Z"
}
]Use the ref_id when creating an invoice or a Checkout Session. You display the price; you never set it.
6.2 Create an invoice — POST /api/v1/core/partner/invoices/
Creates an invoice for a guest and emails them an Onfire-hosted payment link. The amount is derived server-side from the rate card — you cannot supply it.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
ref_id | string | yes | The rate card's ref_id. |
external_invoice_ref | string | yes | Your invoice id. Idempotency key (see below). |
client_email | string (email) | yes | Guest email (where the pay link is sent). |
client_name | string | yes | Guest name. |
client_phone | string | yes | Guest phone. |
client_billing_address | object | yes | line1, city, state, postal_code required; line2, country optional. |
external_client_ref | string | no | Your id for the guest; echoed back on webhooks to correlate multiple invoices for the same guest. |
curl -X POST "$BASE/api/v1/core/partner/invoices/" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{
"ref_id": "rc_7c2a91e4",
"external_invoice_ref": "BIO-2026-000123",
"client_email": "guest@example.com",
"client_name": "Jane Doe",
"client_phone": "+14155550101",
"client_billing_address": {"line1":"1 Market St","city":"San Francisco","state":"CA","postal_code":"94105"},
"external_client_ref": "patient_5567"
}'Response 201 (InvoiceResponse, abridged):
{
"invoice_public_id": "inv_3Kp9...",
"status": "open",
"amount": "2050.00",
"currency": "USD",
"external_invoice_ref": "BIO-2026-000123",
"external_client_ref": "patient_5567",
"pay_url": "https://payment.onfirehealth.com/pay/inv_3Kp9...",
"created_at": "2026-06-24T17:00:00Z"
}Onfire sends the email, you can store the pay_url to show in the UI for user to manually send/resend. You receive an invoice.open webhook immediately, then invoice.paid once they pay.
Idempotency. external_invoice_ref is unique per practitioner. Re-POSTing the same ref returns the existing invoice (same invoice_public_id) with no duplicate and no second event. Use a stable ref and retry safely.
6.3 Reading invoices
Outbound webhooks are the primary way invoice state reaches a Platform, but delivery is at-least-once and can be missed. These read endpoints let a Platform reconcile or backfill — poll for the current state, or look an invoice up by your own reference. They use the same Authorization: Bearer <access_token> credential as create, and both are scoped to the practitioner AND the calling Connected App: a Platform only ever sees the invoices it created — the same isolation as webhook delivery. Invoices created by another Platform, or from the partner portal, are never returned.
List invoices — GET /api/v1/core/partner/invoices/
| Query param | Type | Default | Notes |
|---|---|---|---|
status | invoice status | — | Filter by lifecycle status: open, processing, paid, void, refunded, returned. |
external_invoice_ref | string | — | Reconcile a single invoice by your own reference (exact match). |
skip | int | 0 | Pagination offset. |
limit | int | 100 | Page size, 1–200. |
Results are newest-first, wrapped in a pagination envelope:
curl -X GET "$BASE/api/v1/core/partner/invoices/?status=open&limit=50" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"{
"invoices": [
{
"invoice_id": "3f2a1c4e-9b7d-4a2f-8c1e-0d6a5b4c3e21",
"invoice_public_id": "inv_9xKq…",
"partner_id": 7,
"status": "open",
"amount": "150.00",
"currency": "USD",
"client_email": "client@example.com",
"external_invoice_ref": "your-ref-1",
"rate_card_ref": "HC-001",
"rate_card_name": "General Consultation",
"pay_url": "https://pay.onfirehealth.com/pay/inv_9xKq…",
"created_at": "2026-06-08T12:00:00Z",
"updated_at": "2026-06-08T12:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 50
}Retrieve one invoice — GET /api/v1/core/partner/invoices/{invoice_id}
{invoice_id} is the Onfire UUID returned in the create response. If you only hold your own reference, use the list endpoint's external_invoice_ref filter instead. A 404 is returned for an unknown id — or one belonging to another partner or Connected App, since a cross-tenant read is indistinguishable from not-found.
curl -X GET "$BASE/api/v1/core/partner/invoices/3f2a1c4e-9b7d-4a2f-8c1e-0d6a5b4c3e21" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"Returns a single invoice object — the same shape as an item in the list above. POST create response returns this same object shape too (including rate_card_ref), so you can persist the full record straight from create. The error semantics (§9) apply to these endpoints too (401 ⇒ refresh/re-auth, 403 ⇒ scope/consent).
6.4 Checkout Sessions — POST /api/v1/core/partner/checkout-sessions
A hosted payment page for one charge. If you already integrate Stripe Checkout, this is the same loop: create a Checkout Session server-side when the payer is ready, send them to its URL, and when they land back on your success_url retrieve the session and record the payment. Onfire does not email a pay link for this path — you send the payer to payer_url.
Unlike the invoice routes, this path has no trailing slash.
Create
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes | Exactly one item, priced from the practitioner's rate card. Each item is ref_id (the rate card's string ref_id from §6.1) and quantity. There is no inline price. Sending amount or any unknown key is a 422. |
client_reference_id | string | no | Your booking or order id. When present, it makes create idempotent: the same body again returns 200 with the same session; a different body under the same key is 409. |
success_url | string (URL) | no | Must be https://. Your own query string is preserved. See the redirect below. |
cancel_url | string (URL) | no | Must be https://. A plain link: nothing is appended, and the session stays open. |
metadata | object | no | JSON, at most 2 KB. Echoed back on every read and on every webhook, verbatim. |
expires_at | timestamp | no | 30 minutes to 30 days ahead. Default is 24 hours. |
curl -X POST "$BASE/api/v1/core/partner/checkout-sessions" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{
"items": [{"ref_id": "rc_7c2a91e4", "quantity": 1}],
"client_reference_id": "booking-123",
"success_url": "https://app.example.com/selfbooking/123?client=abc",
"cancel_url": "https://app.example.com/selfbooking/123",
"metadata": {"event_template_id": "123"}
}'Response 201 (abridged). It carries id (UUID), public_id (cs_…), payer_url (send the payer here), amount, currency, status: open, payment_status: unpaid, and everything you authored. amount is the In Full total off the rate card, as a decimal string.
{
"id": "6d5f1a2b-3c4d-5e6f-7081-92a3b4c5d6e7",
"public_id": "cs_MOqbx9zX…",
"payer_url": "https://payment.onfirehealth.com/checkout/cs_MOqbx9zX…",
"amount": "2050.00",
"currency": "USD",
"status": "open",
"payment_status": "unpaid",
"items": [{"ref_id": "rc_7c2a91e4", "quantity": 1, "position": 0}],
"client_reference_id": "booking-123",
"success_url": "https://app.example.com/selfbooking/123?client=abc",
"cancel_url": "https://app.example.com/selfbooking/123",
"metadata": {"event_template_id": "123"},
"expires_at": "2026-06-25T17:00:00Z",
"completed_at": null
}An identical retry — same client_reference_id and the same body — returns 200 with that same session. A different body under the same client_reference_id is 409. There is no list endpoint. Keep your own client_reference_id → public_id mapping from this response if you need to find sessions later.
The redirect back
On completion the hosted page offers the payer a Return button to your success_url with checkout_session_id=<public_id> appended — for example https://app.example.com/selfbooking/123?client=abc&checkout_session_id=cs_MOqbx9zX…. Two differences from Stripe are worth designing for:
- It is a button, not an automatic redirect. A payer who closes the tab never arrives. Treat the redirect as the UX return path, and the
checkout_session.payment_paid/checkout_session.payment_processingwebhooks as the record of truth. - There is no
{CHECKOUT_SESSION_ID}template. The parameter is always appended under that fixed name. A literal placeholder in your URL passes through untouched.
Retrieve — GET /api/v1/core/partner/checkout-sessions/{id}
{id} is either the UUID from the create response or the cs_… public_id that arrives on the redirect, so you can retrieve straight off the redirect without keeping a map. Unknown, cross-tenant, and malformed ids are all the same 404.
# {id} is the UUID from create, or the cs_… public_id from the redirect
curl -X GET "$BASE/api/v1/core/partner/checkout-sessions/cs_MOqbx9zX" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"The body carries status (open → complete / expired / cancelled), payment_status (unpaid → processing → paid), amount / currency, completed_at, items, client_reference_id, metadata, and the URLs you authored. Card in full goes to paid synchronously; bank transfers sit in processing until they settle. Record the payment when status is complete, and treat payment_status of processing as pending settlement. The error semantics (§9) apply here too.
7. Webhooks
Onfire delivers invoice.* and checkout_session.* events for all your connected practitioners to the single endpoint registered for your Connected App. Route each event by partner_public_id.
7.1 Register your endpoint
Your platform-wide webhook endpoint (the single endpoint that receives every connected practitioner's invoice.* and checkout_session.* events for your Connected App) is provisioned by Onfire, not self-service — it's tied to your client_id, and setting it up requires access Platforms don't have. Send your receiver URL to partner@onfirehealth.com (the same contact as onboarding, §3) and we'll register it and send you the signing secret whsec_… (store it securely). Reach out to the same address to rotate the secret, disable/re-add the endpoint, or request a test delivery.
Note.
Settings → Developerin the partner portal (https://partner.onfirehealth.com/settings/developer) is a different, practitioner-level self-service page — it lets an individual practitioner register a webhook endpoint scoped to their own account only. It is not the platform-wide endpoint described here, and registering there will not receive events for the practitioners connected through your Connected App.
7.2 Event types
| Event | When |
|---|---|
invoice.open | Invoice created; awaiting payment. |
invoice.processing | Payment initiated (e.g. ACH in flight). |
invoice.paid | Payment settled. |
invoice.voided | Invoice voided. |
invoice.refunded | Payment refunded. |
invoice.returned | Payment was returned (e.g. an ACH transaction reversed after it had settled). |
checkout_session.payment_processing | A Checkout Session bank transfer has been initiated and is pending settlement. The record of truth for that payment — the success_url return is only the UX path (§6.4). |
checkout_session.payment_paid | A Checkout Session payment has settled. Card in full lands here synchronously; a bank transfer arrives here after payment_processing. |
webhook.ping | Connectivity test sent when the endpoint is registered. Carries no invoice. |
7.3 Payload
A versioned, Stripe-style envelope. PHI-minimal — correlation ids + money/status only. Invoice events use data.invoice. Checkout Session events use data.checkout_session, and echo the metadata you authored verbatim. Route either family by partner_public_id.
{
"id": "evt_b6b96615cb3d4172a5855ee92cf42d36",
"type": "invoice.paid",
"api_version": "2026-06-01",
"created": 1782755598,
"data": {
"invoice": {
"partner_public_id": "prt_9f1c3a7b8d2e4f60a1b2c3d4e5f60718",
"invoice_public_id": "inv_3Kp9...",
"status": "paid",
"external_invoice_ref": "BIO-2026-000123",
"external_client_ref": "patient_5567",
"amount": "2050.00",
"currency": "USD",
"ref_id": "rc_7c2a91e4",
"payment_reference": "pay_…"
}
}
}{
"id": "evt_c7c07726dc4e5283b6966ff03d053e47",
"type": "checkout_session.payment_paid",
"api_version": "2026-06-01",
"created": 1782755598,
"data": {
"checkout_session": {
"id": "6d5f1a2b-3c4d-5e6f-7081-92a3b4c5d6e7",
"public_id": "cs_MOqbx9zX…",
"partner_public_id": "prt_9f1c3a7b8d2e4f60a1b2c3d4e5f60718",
"status": "complete",
"payment_status": "paid",
"created_via": "oauth",
"client_reference_id": "booking-123",
"invoice_public_id": null,
"amount": "2050.00",
"currency": "USD",
"items": [{"ref_id": "rc_7c2a91e4", "quantity": 1}],
"expires_at": "2026-06-25T17:00:00Z",
"completed_at": "2026-06-24T18:00:00Z",
"metadata": {"event_template_id": "123"}
}
}
}7.4 Verifying the signature (required)
Every delivery includes:
X-Onfire-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>The signed payload is "{t}." + <raw_request_body>, HMAC-SHA256 with your endpoint's signing secret. Verify over the raw body (before any JSON re-serialization), use a constant-time compare, and reject stale timestamps.
const crypto = require("crypto");
function verifyOnfireWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay guard
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // Buffer/string of the exact bytes received
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the raw body for this route
app.post("/api/webhooks/onfire", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyOnfireWebhook(req.body, req.get("X-Onfire-Webhook-Signature"), process.env.ONFIRE_WEBHOOK_SIGNING_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.type
// route by event.data.invoice.partner_public_id
// or event.data.checkout_session.partner_public_id
res.status(200).end(); // ack fast
});A separate credential from OAuth. Outbound webhooks do not use the OAuth access token — they are HMAC-SHA256 signed with a per-endpoint signing secret. That secret does not expire and has no refresh flow; it is rotated manually via
POST /api/v1/meta/partner-webhook-endpoints/{id}/rotate-secret, after which you must reconfigure your receiver with the new secret.
7.5 Delivery semantics
- Acknowledge with
2xxwithin ~10s. Any non-2xx (or a timeout) is a failed attempt. - No redirects. Onfire does not follow
3xx— a redirect counts as failure. Your receiver must serve the POST directly, with no auth wall, bot-gate, or interstitial in front of it. - Retries. ~7 attempts over ~24h with backoff (
1m, 5m, 30m, 2h, 6h, 24h, ±10% jitter), then the event is dead-lettered. - Idempotency & ordering. Dedupe on the envelope
id(events may be redelivered). Don't assume strict ordering; reconcile invoices onstatus+external_invoice_ref, and Checkout Sessions onstatus/payment_status+public_idorclient_reference_id.
8. End-to-end flow
Invoice
1. (once per practitioner) OAuth consent → store access + refresh tokens
2. Decode partner_public_id from the access token, or GET /meta/partners/me
3. GET /meta/partner-rate-cards/ → show offerings (ref_id, price)
4. POST /core/partner/invoices/ → 201 + pay_url; guest pays on Onfire
5. Receive invoice.open, then invoice.paid at your webhook endpoint
6. Verify signature → route by partner_public_id → reconcile by external_invoice_ref
Checkout Session
1–3. Same connection, practitioner, and rate card
4. POST /core/partner/checkout-sessions → 201 + payer_url; send the payer there
5. Payer may return to success_url with ?checkout_session_id=cs_… (a button, not an automatic redirect)
6. Record payment from checkout_session.payment_paid or checkout_session.payment_processing
7. GET /core/partner/checkout-sessions/{id} to confirm; record when status is complete9. Errors
| Status | Meaning |
|---|---|
401 | Missing/invalid/expired access token. Refresh or re-authenticate and retry — see the OAuth semantics below. |
403 | Insufficient payment-connection scope. Don't retry — fix the grant; see below. |
404 | Rate card ref_id not found (or not active) for this practitioner, or a Checkout Session id that is unknown, cross-tenant, or malformed. |
409 | Conflict. For a Checkout Session, the same client_reference_id was sent with a different body. |
422 | Validation error (missing/invalid fields). |
429 | Rate limited (see below). |
Error bodies are JSON: { "detail": "<message>" }.
9.1 OAuth error semantics
Because Onfire only validates the token, auth failures map to a small set of precise detail messages. Each has a specific correct response:
| HTTP | detail | What it means | What to do |
|---|---|---|---|
401 | OAuth access token expired | The access token's exp has passed. | Refresh the access token and retry the request once. |
401 | Invalid or expired Stytch session / Missing authentication… | No usable credential — missing or garbled token, or a non–Connected-App token. | Re-authenticate. Verify the Authorization: Bearer header is present and well-formed. |
403 | Insufficient scope: payment-connection required | The token is valid but lacks the payment-connection scope. | Do not retry. Fix the grant — the practitioner must consent with the right scope. |
Rule of thumb: 401 ⇒ refresh or re-authenticate; 403 ⇒ scope/consent problem (fix the grant, don't retry). All 401 responses include a WWW-Authenticate: Bearer header.
10. Rate limits
The partner OAuth endpoints are rate-limited per connection (per practitioner), so one practitioner's volume never throttles another. On 429, back off and retry. If you expect high burst volume, contact Onfire.
11. Versioning
The webhook envelope carries api_version (currently 2026-06-01). Additive fields may be introduced without a version bump — ignore unknown fields and don't assume field order. Breaking changes ship under a new api_version.
12. Support
Reach out to your Onfire contact for credentials, sandbox access, or to update your redirect URI or webhook URL.
