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, and receive lifecycle events. It covers authentication, the rate-card and invoice 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 & create invoices — show offerings and create invoices; the guest pays on an Onfire-hosted page (§6).
  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. 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.
GuestThe practitioner's customer who pays. They pay on an Onfire-hosted page (pay_url); 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) — 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: 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 (it redirects to login first if not), and gates on payout verification — if their account setup isn't complete, they see a payout-setup screen to be completed, instead of the consent screen. Only past that gate does the user see the consent widget.
  3. On approval: Onfire redirects back to your redirect_uri with ?code=…&state=….
    On denial — the practitioner declines consent, or abandons payout setup — 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, ... }

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 does not contain partner_public_id. Call /me once per connection (e.g. right after consent) and store the mapping partner_public_id → your tenant.

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.

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. 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 statusFilter by lifecycle status: open, processing, paid, void, refunded, returned.
external_invoice_refstringReconcile 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).

7. Webhooks

Onfire delivers invoice.* 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.* 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).
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.

{
  "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_…"
    }
  }
}

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
  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 on status + external_invoice_ref.

8. End-to-end flow

1. (once per practitioner) OAuth consent → store access + refresh tokens
2. GET /meta/partners/me → store partner_public_id → your tenant
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

9. Errors

StatusMeaning
401Missing/invalid/expired access token. Refresh or re-authenticate and retry — see the OAuth semantics below.
403Practitioner not payout-verified, token can't be resolved to a practitioner, or insufficient scope. Don't retry — fix the grant; see below.
404Rate card ref_id not found (or not active) for this practitioner.
409Conflict (e.g. duplicate resource).
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.