Step 1
Get a sandbox key.
One public, rate-limited call. The key is shown once — save it.
curl https://api.opendoc.com/developers/signup \
-X POST \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "name": "Your Name" }'
Response (201):
{
"data": {
"developerAccountId": "…",
"apiKey": "odk_sandbox_…", // shown ONCE — store it now
"keyMode": "sandbox",
"sandboxPatientId": "…", // the synthetic patient your key acts as
"sandboxProviderHsoId": "…", // the ONE offering your key may book
"docs": "https://docs.opendoc.com",
"quickstart": "curl -H \"Authorization: Bearer <apiKey>\" https://api.opendoc.com/search?q=knee%20surgery",
"note": "SANDBOX key: payments are simulated (non-live) and only the sandbox provider is bookable. …"
}
}
Export the two values the rest of this page uses:
export OPENDOC_KEY="odk_sandbox_…" export SANDBOX_OFFER_ID="<sandboxProviderHsoId from the response>"
Step 2
Understand what your key already carries.
Real patients must create a Health Key, promote it to IA2, and grant Layer-2 consent before transacting (the ceremony in the Auth guide). Signup already did all of that for your sandbox subject: it provisioned a synthetic patient with an active Health Key at IA2 and Layer-2 BOOK and CANCEL consent. The same gates that protect real patients run on every call — your sandbox subject simply passes them from the start.
odk_sandbox_ key as a Bearer token and go straight to the transaction flow. Building the ceremony itself is only needed when you integrate real patient identity.Step 3
Know the stable sandbox fixtures.
The sandbox world is seeded with fixed IDs so integration tests can pin them. These are sandbox-only anchors — they never exist in live data, and a sandbox key can only book the sandbox offering.
SANDBOX_SCP_ID5a4d0000-0000-4000-8000-0000000005c9 — "OpenDoc Sandbox Clinic", the synthetic service counterparty.SANDBOX_PROVIDER_ID5a4d0000-0000-4000-8000-0000000009d0 — "Sandy Sandbox, MD (SANDBOX)", the synthetic provider.SANDBOX_HSO_SLUGsandbox-demo-visit — the bookable "Sandbox Demo Visit" service ($50.00 synthetic price).sandboxProviderHsoIdThe rate-card (offer) ID returned by your signup response — this is the providerHsoId you pass to declare-intent.availabilitySlotId entirely.Step 4 (optional)
Look around with public discovery.
Search and availability are public reads on the production engine. Useful for orientation; not required for the sandbox booking below, because signup already gave you the offer ID and the slot is auto-minted.
# Search the real provider directory curl "https://api.opendoc.com/search?q=knee%20surgery" \ -H "Authorization: Bearer $OPENDOC_KEY" # Open slots for the sandbox provider (next 30 days by default) curl "https://api.opendoc.com/providers/5a4d0000-0000-4000-8000-0000000009d0/availability"
Step 5
Drive the booking: S0 → S4.
Four state transitions, each with an idempotency key. This is the same state machine live mode runs — sandbox only swaps in the synthetic provider and simulated (non-live) payments.
# S0 → S1: declare intent
curl https://api.opendoc.com/transactions/declare-intent \
-X POST \
-H "Authorization: Bearer $OPENDOC_KEY" \
-H "Content-Type: application/json" \
-d "{ \"providerHsoId\": \"$SANDBOX_OFFER_ID\", \"idempotencyKey\": \"qs-001-declare\" }"
# → 201 { "data": { "transactionId": "…", "state": "S1_intent",
# "cashPriceCents": …, "platformFeeCents": …, "currency": "usd",
# "hsoTitle": "Sandbox Demo Visit" } }
export TXN_ID="<transactionId from the response>"
# S1 → S2: authorize (price lock)
curl https://api.opendoc.com/transactions/$TXN_ID/authorize \
-X POST \
-H "Authorization: Bearer $OPENDOC_KEY" \
-H "Content-Type: application/json" \
-d '{ "idempotencyKey": "qs-001-authorize" }'
# → 200 { "data": { "transactionId": "…", "state": "S2_authorized",
# "replayed": false,
# "priceLock": { "jws": "…", "claims": { "max_obligation_cents": …, … } } } }
# S2 → S3: accept terms (payment boundary; simulated in sandbox)
curl https://api.opendoc.com/transactions/$TXN_ID/accept-terms \
-X POST \
-H "Authorization: Bearer $OPENDOC_KEY" \
-H "Content-Type: application/json" \
-d '{ "idempotencyKey": "qs-001-terms" }'
# → 200 { "data": { "transactionId": "…", "state": "S3_terms_accepted",
# "replayed": false, "payment": { "mode": "non_live", … } } }
# S3 → S4: atomic commit
curl https://api.opendoc.com/transactions/$TXN_ID/commit \
-X POST \
-H "Authorization: Bearer $OPENDOC_KEY" \
-H "Content-Type: application/json" \
-d '{ "idempotencyKey": "qs-001-commit" }'
# → 200 { "data": { "transactionId": "…", "state": "S4_committed",
# "replayed": false, "payment": { … } } }
priceLock.jws returned by authorize is a signed claim of the maximum obligation for this transaction — verify and store it. Retrying any step with the same idempotencyKey replays the original transition ("replayed": true) instead of double-booking.Step 6
Read the transaction state.
curl https://api.opendoc.com/transactions/$TXN_ID \
-H "Authorization: Bearer $OPENDOC_KEY"
# → 200 { "data": { "transactionId": "…", "state": "S4_committed",
# "booking": { "id": "…", "status": "…", "cashPriceCents": …,
# "currency": "usd", "scheduledFor": "…",
# "fulfilledAt": null, "canceledAt": null },
# "payment": { … } } }
Next
Where to go from here.
GET /protocolThe live machine-readable contract: routes, tools, auth, event types. OpenAPI at GET /protocol/openapi.json.transaction.state_changed and friends over SSE or webhooks.