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:

  1. Onboarding — get your client_id/client_secret from Onfire; the URLs and scopes you'll need are already documented (§3).
  2. Connect a practitioner — one-time OAuth consent per practitioner; store the resulting access + refresh tokens (§4).
  3. Resolve the practitioner — call /meta/partners/me once per connection to get their partner_public_id (§5).
  4. 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).
  5. Register your endpoint, then receive & verify webhooks — self-service from your partner account; verify the signature over the raw request body (§7).

1. Concepts

TermMeaning
PlatformYour application. You hold one OAuth client (Connected App) and connect many practitioners through it.
ConnectionOne practitioner's authorization of your Platform, established once via OAuth consent. You hold N connections (one access/refresh token pair per practitioner).
PractitionerThe 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 cardA 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.
InvoiceA 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 SessionA 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.
GuestThe 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

PurposeURL
API basehttps://api-stage.onfirehealth.com
Authorize URLhttps://staging-partner.onfirehealth.com/oauth/authorize
Token URLhttps://login-staging-partner.onfirehealth.com/v1/public/project-test-51833b68-1bab-4bf9-b670-544c5785af15/oauth2/token

Production

PurposeURL
API basehttps://api.onfirehealth.com
Authorize URLhttps://partner.onfirehealth.com/oauth/authorize
Token URLhttps://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-connection scope — 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 your client_id/client_secret and no code) returns unauthorized_client.

4.1 Connect a practitioner (one-time consent)

This is the actionable sequence:

  1. Generate a random, unguessable state value 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.
  2. Redirect the practitioner to the authorize URL (§2) with your client_id, redirect_uri, response_type=code, that state, and the requested scope=payment-connection offline_access. Both scopes are required: payment-connection grants access to the partner APIs, and offline_access is what makes Stytch return a refresh token.

    For example, against staging with dummy values:
    # 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-sha
    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.

    redirect_uri on this URL is informational. The value that actually sends the practitioner back to you is the one registered against your client_id. Keep the query-string value identical to the registered one, and do not treat the query string as your security boundary.
  3. On approval: Onfire redirects back to your registered redirect_uri with ?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 returned state matches 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. If error is present, stop; do not attempt a token exchange.
  4. Exchange the code at Stytch's token endpoint — the Token URL for your environment, from §2 — using HTTP Basic auth with client_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

FieldTypeRequiredNotes
ref_idstringyesThe rate card's ref_id.
external_invoice_refstringyesYour invoice id. Idempotency key (see below).
client_emailstring (email)yesGuest email (where the pay link is sent).
client_namestringyesGuest name.
client_phonestringyesGuest phone.
client_billing_addressobjectyesline1, city, state, postal_code required; line2, country optional.
external_client_refstringnoYour 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 paramTypeDefaultNotes
statusinvoice status—Filter by lifecycle status: open, processing, paid, void, refunded, returned.
external_invoice_refstring—Reconcile a single invoice by your own reference (exact match).
skipint0Pagination offset.
limitint100Page 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

FieldTypeRequiredNotes
itemsarrayyesExactly 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_idstringnoYour 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_urlstring (URL)noMust be https://. Your own query string is preserved. See the redirect below.
cancel_urlstring (URL)noMust be https://. A plain link: nothing is appended, and the session stays open.
metadataobjectnoJSON, at most 2 KB. Echoed back on every read and on every webhook, verbatim.
expires_attimestampno30 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:

  1. 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_processing webhooks as the record of truth.
  2. 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 → Developer in 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

EventWhen
invoice.openInvoice created; awaiting payment.
invoice.processingPayment initiated (e.g. ACH in flight).
invoice.paidPayment settled.
invoice.voidedInvoice voided.
invoice.refundedPayment refunded.
invoice.returnedPayment was returned (e.g. an ACH transaction reversed after it had settled).
checkout_session.payment_processingA 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_paidA Checkout Session payment has settled. Card in full lands here synchronously; a bank transfer arrives here after payment_processing.
webhook.pingConnectivity 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 2xx within ~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 on status + external_invoice_ref, and Checkout Sessions on status / payment_status + public_id or client_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 complete

9. Errors

StatusMeaning
401Missing/invalid/expired access token. Refresh or re-authenticate and retry — see the OAuth semantics below.
403Insufficient payment-connection scope. Don't retry — fix the grant; see below.
404Rate card ref_id not found (or not active) for this practitioner, or a Checkout Session id that is unknown, cross-tenant, or malformed.
409Conflict. For a Checkout Session, the same client_reference_id was sent with a different body.
422Validation error (missing/invalid fields).
429Rate 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:

HTTPdetailWhat it meansWhat to do
401OAuth access token expiredThe access token's exp has passed.Refresh the access token and retry the request once.
401Invalid 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.
403Insufficient scope: payment-connection requiredThe 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.