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:
- 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 & create invoices — show offerings and create invoices; the guest pays on an Onfire-hosted page (§6).
- 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. 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. |
| Guest | The 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
| 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: against staging with dummy values:
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.# 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 - On approval: Onfire redirects back to your
redirect_uriwith?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 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, ... }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
| 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).
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 → 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). |
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.
{
"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
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 onstatus+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_ref9. Errors
| Status | Meaning |
|---|---|
401 | Missing/invalid/expired access token. Refresh or re-authenticate and retry — see the OAuth semantics below. |
403 | Practitioner not payout-verified, token can't be resolved to a practitioner, or insufficient scope. Don't retry — fix the grant; see below. |
404 | Rate card ref_id not found (or not active) for this practitioner. |
409 | Conflict (e.g. duplicate resource). |
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.
