Lobbie Partner API (2.0)
Before making API requests, you must complete the partner onboarding process:
- Partner Registration: Lobbie creates a partner integrator record with your unique
client_id - Cognito Credentials: Lobbie provides OAuth 2.0 client credentials (client ID and client secret)
- API Gateway Key: Lobbie provides an
x-api-keyvalue that identifies your integration for rate limiting (see Rate Limits & Quotas) - Account Grants: Lobbie grants your integration access to specific accounts
All three credentials (client_id, client_secret, and x-api-key) are shown only once at onboarding — store them securely. If any are lost or compromised, contact Lobbie support immediately.
Request an access token using the OAuth 2.0 client credentials by sending an HTTPS form POST. Include the x-api-key header — the token endpoint is gated by the same API key as the rest of the Partner API:
POST https://api.lobbie.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
x-api-key: <your_api_key>
grant_type=client_credentials&
scope=prod-lobbie-api/partner-api&
client_id=<your_client_id>&
client_secret=<your_client_secret>The scope value is environment-prefixed. Use prod-lobbie-api/partner-api for production; Lobbie will provide the appropriate value for any non-production environment at onboarding.
The response contains a JWT access token:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}Cache this token. Tokens are valid for 60 minutes and the token endpoint has a much tighter rate limit than the main API (see Rate Limits & Quotas). A well-behaved integration fetches a token once and reuses it until shortly before it expires.
Every API request must include both headers: the OAuth Bearer token (which authenticates you) and the API key (which identifies you to the gateway for rate limiting):
Authorization: Bearer <access_token>
x-api-key: <your_api_key>Requests missing the x-api-key header are rejected at the API Gateway edge with 403 Forbidden before reaching the application. Requests with an invalid or revoked key are similarly rejected.
- Access tokens expire after the duration specified in
expires_in(default: 60 minutes) - Request a new token before the current one expires
- Store credentials securely—they grant access to protected health information (PHI)
- If credentials are compromised, contact Lobbie support immediately to revoke and regenerate
Your partner integration must have an active grant to access each account. The API validates:
- The JWT token is valid and not expired
- Your
client_idcorresponds to an active partner integrator - Your integration has an active grant to the requested account
- The requested location belongs to the specified account
Limits are enforced per x-api-key. Two separate limits apply depending on which endpoint you're calling:
General API (/lobbie/api/partner/v2/*) — sustained rate limit 25 requests/second with a daily quota of 250,000 requests. We recommend targeting around 20 requests/second in steady-state to absorb natural timing variability.
Token endpoint (/oauth2/token) — enforced at a much tighter per-partner rate than the rest of the API. A correctly-implemented integration fetches a token once per hour and caches it until shortly before it expires; with that behavior you'll never approach the limit. If you're seeing 429s on /oauth2/token, your client is refreshing tokens far more often than needed — add caching rather than asking for a higher limit. Token calls count against the same daily quota as the rest of the API.
The daily quota resets at 00:00 UTC. Exceeding either limit returns 429 Too Many Requests with a JSON body and a Retry-After header. The two causes are distinguished by the machine-readable code field — switch on code, don't parse the message prose:
code | Cause | Retry-After | Response body | Expected back-off |
|---|---|---|---|---|
THROTTLED | Rate too high | 1 | {"code":"THROTTLED","message":"API rate limit exceeded. Retry after a short delay; throttle capacity refills within seconds."} | Wait the second in Retry-After, then retry. Throttle capacity refills continuously. |
QUOTA_EXCEEDED | Daily quota exhausted | 3600 | {"code":"QUOTA_EXCEEDED","message":"Daily request quota exhausted. The quota resets at 00:00 UTC."} | Stop until the quota resets at 00:00 UTC. Retry-After is an hourly re-check hint, not the exact reset — retrying before 00:00 UTC keeps returning QUOTA_EXCEEDED. |
Retry-After is expressed in seconds. Read code to decide how to react: THROTTLED clears within seconds, while QUOTA_EXCEEDED does not clear until the daily reset — treat them as short-wait and stop-until-reset respectively.
If your integration consistently approaches these limits, contact Lobbie.
| Status | Error | Description |
|---|---|---|
| 400 | Bad Request | Malformed body, a required field missing, or a value that is not one of the published values |
| 401 | Unauthorized | Invalid, expired, or missing access token |
| 403 | Forbidden | Missing/invalid x-api-key, or integration lacks access to the requested account or location |
| 404 | Not Found | Resource not found or does not belong to the authorized account |
| 422 | Unprocessable Entity | Validation failed — see error.fields for per-property details |
| 429 | Too Many Requests | Rate limit or daily quota exceeded (see Rate Limits & Quotas) |
Lobbie can POST JSON event payloads to an HTTPS URL you configure. Managing webhook endpoints (create, list, update, delete, discover event types, send a test delivery) uses the Partner API with the same Bearer token and x-api-key as Making Authenticated Requests.
- List subscribable event types —
GET /lobbie/api/partner/v2/webhook/event-typesreturns the event type strings you may use ineventTypeswhen creating or updating an endpoint (alphabetical list; source of truth as new types are added). - Create an endpoint —
POST /lobbie/api/partner/v2/webhookwith a JSON body:
url— must be HTTPS with a public host (Lobbie validates the URL).eventTypes— non-empty array; every value must appear inGET .../event-types.name— optional label.
- Signing secret — On create only, the response includes
signingSecret. Store it immediately; it is not returned on later GET/list responses.
Treat
signingSecretlike a password. You need it to verifyLobbie-Signatureon inbound POSTs. If it is lost, delete the endpoint and create a new one.
Read, update, list, delete, and test-send are documented under the Webhooks tag in OpenAPI (paths under /lobbie/api/partner/v2/webhook).
When creating or updating an endpoint, eventTypes must contain one or more of the following subscribable event types. The same list is returned alphabetically by GET /lobbie/api/partner/v2/webhook/event-types (the source of truth as new types are added).
| Event type | Description |
|---|---|
appointment.created | A new appointment was created |
appointment.updated | An existing appointment was updated |
appointment.deleted | An appointment was deleted |
patient.created | A new patient was created |
patient.updated | An existing patient was updated |
patient.deleted | A patient was deleted |
form-packet.created | A new form packet was created |
form-packet.updated | An existing form packet was updated |
payment.created | A successful charge was recorded (not emitted by all accounts — see Payment events) |
payment-attempt.created | A payment attempt was first recorded (see Payment events) |
payment-attempt.updated | A payment attempt's state changed (see Payment events) |
subscription.created | A subscription was created (see Subscription events) |
subscription.updated | A subscription changed, including cancellation (see Subscription events) |
lab-order.submitted | Staff placed a lab order — new work for the lab partner (see Lab order events) |
test.ping is used only for test deliveries (POST .../webhook/{webhookId}/test); it is not subscribable and does not appear in GET .../event-types.
POST /lobbie/api/partner/v2/webhook/{webhookId}/test triggers an immediate outbound POST to your configured URL so you can validate connectivity, timeouts, and signature verification. The event type for this delivery is test.ping; it is not included in GET .../event-types and is not something you subscribe to.
The request body requires an accountId you've been granted access to. Webhook endpoints are integrator-scoped and serve every account you're granted, so there is no implicit account to test under — you name one explicitly. The value is published verbatim as the delivery envelope's accountId, exactly as it would be on a real event. A request naming an account you can't access returns 404.
Lobbie delivers events with:
| Aspect | Value |
|---|---|
| Method | POST |
Content-Type | application/json |
| Body | UTF-8 JSON (see below) |
Headers you should read:
| Header | Purpose |
|---|---|
Lobbie-Signature | t=<unix_seconds>,v1=<hex_hmac_sha256> (see Verifying signatures) |
Lobbie-Webhook-Event-Id | Unique id for the event (e.g. evt_...); use for idempotency / deduplication |
Lobbie-Webhook-Event-Type | For the test endpoint: test.ping. Otherwise matches your subscription (e.g. appointment.created) |
The JSON body is an envelope plus an event-specific data object:
| Field | Description |
|---|---|
version | Payload format version (e.g. 1) |
eventId | Same id as Lobbie-Webhook-Event-Id |
eventType | Same string as Lobbie-Webhook-Event-Type |
accountId | Lobbie account the event source belongs to. Stable for the lifetime of the event and present on every delivery — use it to route incoming events without joining on fields inside data. For test.ping there is no source entity, so this is the accountId you supplied in the test request. |
environment | Lobbie deployment identifier |
occurredAt | When the event occurred (ISO-8601 instant) |
sentAt | When this delivery was sent (ISO-8601 instant) |
deliveryAttempt | Attempt number for this delivery (starts at 1; increments if Lobbie retries) |
data | Event payload (always a JSON object); see below |
Example (illustrative shape only):
{
"version": "1",
"eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
"eventType": "appointment.created",
"accountId": 12345,
"environment": "prod",
"occurredAt": "2025-03-15T14:30:00Z",
"sentAt": "2025-03-15T14:30:01Z",
"deliveryAttempt": 1,
"data": {}
}The envelope above is published as the
WebhookEnvelopeschema undercomponents.schemasin this OpenAPI document — generate it alongside the REST models to get typed inbound webhook handlers.
For domain events, the data object uses the same JSON structure as the corresponding Partner API success response body—you can reuse the same models and OpenAPI schemas you already use for REST. The exception is the lab-order event, whose data is a thin reference object rather than the full resource.
Example — appointment.created: data is an Appointment object with the same fields and types as the 200 OK body of:
GET /lobbie/api/partner/v2/account/{accountId}/location/{locationId}/appointment/{appointmentId}
Refer to the OpenAPI document for that operation’s response schema.
For test.ping (test send only), data is a small object with endpointId and message.
Lobbie emits three payment-related event types. Pick subscriptions based on what you need to do.
| Event type | When it fires | data shape |
|---|---|---|
payment.created | Once per successful charge — emitted only for accounts on Lobbie's current payments platform; some accounts never emit it. Where present, an authoritative signal that money moved. | Payment (same as the GET .../payments/{paymentId} 200 OK body) |
payment-attempt.created | When a payment attempt is first recorded, before submission. status is typically PENDING. | PaymentAttempt (same as the GET .../payment-attempts/{paymentAttemptId} 200 OK body) |
payment-attempt.updated | When a payment attempt's state changes, including the transition to a terminal status (SUCCEEDED, FAILED, CANCELED). | PaymentAttempt (same shape as .created) |
Subscription guidance:
- For billing integrations (record-of-truth that a charge succeeded): subscribe to
payment-attempt.updatedand treatstatus == SUCCEEDEDas the success signal. This is the only success signal emitted by every account, so it is the reliable choice for billing. - For failure handling (retry workflows, patient notifications, dunning): subscribe to
payment-attempt.updatedand filter onstatus == FAILED. payment.createdis an optional convenience for accounts that emit it (fires once per successful charge). Not all accounts do, so do not rely on it as a universal success signal. Do not use both it andpayment-attempt.updated(SUCCEEDED) as billing triggers for the same account — that double-counts every successful charge. Pick one.
Every successful payment emits payment-attempt.created and payment-attempt.updated (with a terminal status); accounts on Lobbie's current payments platform additionally emit payment.created.
Partial approvals. Some cards — commonly HSA/FSA benefit cards — approve only part of a requested charge. Lobbie records these as successful payments for the amount the card approved: the attempt's status is SUCCEEDED and its amount is the approved amount, not the amount requested, so payment-attempt figures always reconcile against money that actually moved. The unapproved remainder stays owed on the invoice, and collecting it later creates a separate payment attempt. These events may be observed in any order — dedupe retries by eventId, reconcile by occurredAt, and re-read the resource via the corresponding GET endpoint when you need an authoritative current state.
Lobbie emits two subscription event types, for every account regardless of payments platform.
| Event type | When it fires | data shape |
|---|---|---|
subscription.created | When a subscription is created. | Subscription (same as the GET .../subscription/{subscriptionId} 200 OK body) |
subscription.updated | When any field of a subscription changes — status, payment method, terms, or a next-billing-date advance. | Subscription (same shape as .created) |
Subscription guidance:
- Cancellation is a status transition, not a delete. A cancelled subscription is delivered as a
subscription.updatedwhosedata.statusisCANCELLED. There is nosubscription.deletedevent. subscription.updatedfires on any change, not only status transitions. To detect a transition (such as active → cancelled), comparedata.statusagainst the last value you recorded for that subscription — the same pattern used forpayment-attempt.updated.data.statususes the unified vocabulary documented on the Subscription resource (DRAFT,ACTIVE,PENDING,PAUSED,CANCELLED,COMPLETED,FAILED,VOID), identical to what theGET .../subscriptionendpoints return for the same account. On some accounts an overdue or failing-payment subscription is surfaced asACTIVEuntil it is cancelled or completes — the webhook matches theGETendpoint in this respect.- These events may be observed in any order — dedupe retries by
eventId, reconcile byoccurredAt, and re-read the subscription viaGETwhen you need an authoritative current state.
Lab partners that can receive inbound HTTPS may subscribe to lab-order notifications instead of (or in addition to) polling GET .../lab-order. The webhook replaces only the discovery step of the lab-order round trip — you still act through the same /accept, /reject, and /results endpoints, and still read authoritative order detail via GET.
| Event type | When it fires | data shape |
|---|---|---|
lab-order.submitted | When staff place a lab order and it enters SUBMITTED — new work awaiting your pickup. | Lab-order reference (see below) |
Unlike other domain events, data is not the full lab-order body. It is a reference object carrying ids and status only — labOrderId, orderNumber, accountId, locationId, status — and never demographics or analytes. Fetch the order's authoritative detail via GET .../lab-order/{labOrderId}, which keeps clinical data out of the push and the event payload small.
Subscription guidance:
- Webhooks are additive. Polling
GET .../lab-orderremains the default and the fallback — you may poll, subscribe, or both. A partner that cannot receive inbound HTTPS simply does not subscribe. data.statusis the order's status when the event is delivered, not when it occurred. Each delivery attempt reads the order as it stands at that moment, so a retriedlab-order.submittedcan carryACCEPTED(or a later status) if you have already acted on the order. Treat the event as a prompt to fetch the order, and act on its current state fromGET .../lab-order/{labOrderId}.- Your own actions are not pushed. Transitions caused by your
/accept,/reject, and/resultscalls (ACCEPTED,REJECTED,PARTIALLY_RESULTED,RESULTED,CORRECTED) return the updated order synchronously in those responses and are not delivered as events. - You receive an event only for an order you could poll yourself when it is placed: your integration has an active grant to the order's account, holds lab-order access, and is bound to the order's lab vendor — the same checks the
GET .../lab-orderendpoints apply. - These events may be observed in any order — dedupe retries by
eventId, reconcile byoccurredAt, and re-read the order viaGETwhen you need an authoritative current state.
Each inbound POST is signed with HMAC-SHA256 using your endpoint's signingSecret (UTF-8 bytes of the string as the HMAC key).
Parse the Lobbie-Signature header. It uses the form t=<unix_seconds>,v1=<hex_hmac_sha256>.
Compute HMAC-SHA256 over the literal bytes: UTF-8 encoding of t as a decimal digit string, then ASCII . (0x2E), then the raw HTTP request body bytes.
Compare your digest to v1 using a constant-time equality check (compare the decoded digest bytes, not string shortcuts on secrets).
On retries, Lobbie may send a new JSON body: sentAt, deliveryAttempt, and the t value in Lobbie-Signature change to match that attempt. Verify each POST with that request's t and raw body bytes — do not reuse an HMAC you computed for an earlier delivery.
Lobbie will re-send a failed or timed-out request with exponential backoff between attempts after each failure: approximately 2 minutes, 4 minutes, 8 minutes, then 15 minutes. After 5 total attempts (the first attempt plus four retries), the delivery is treated as failed and no further retries are made — total recovery window is roughly half an hour. The deliveryAttempt field in the JSON body indicates which attempt you are handling (1-indexed). Each retry is a new request with an updated body and signature (see Verifying signatures).
Design your handler around these guarantees. They are intrinsic to how delivery works and will not change without a versioned, announced change.
- At-least-once delivery once an event is accepted for delivery. Once an event has been accepted onto the delivery queue, it will be attempted at least once and may arrive more than once (e.g. a retry after your server accepted the request but the response was lost). Deduplicate by
eventId(Lobbie-Webhook-Event-Id) and make your handler idempotent.eventIdandoccurredAtare stable across every retry of the same event; onlysentAt,deliveryAttempt, and the signaturetchange per attempt. Note that acceptance onto the delivery queue happens after the originating database transaction commits and is best-effort; in the rare case the queue is unreachable at that moment the event may not be delivered. Lobbie alerts internally on those failures. - No ordering guarantee. Events are delivered over an unordered queue. Do not assume that delivery order matches the order changes occurred — even for the same entity. Order and reconcile on your side using
occurredAt(andeventIdto dedupe). For example, anappointment.updatedmay arrive before theappointment.createdfor the same appointment. - No backfill for endpoints registered after an event occurs. Subscriptions are evaluated at the moment the entity changes, not at delivery time. An endpoint created (or a newly added event-type subscription) after a change occurred will not receive that earlier event. There is no replay; only changes that happen after the subscription exists are delivered.
- Delete events carry metadata only. For deletion events the entity no longer exists by delivery time, so the payload's
datacontains identity only — the entity type and its id — and no entity body. The envelope (eventId,occurredAt,eventType) still applies. Use the priorcreated/updatedevents (deduplicated byeventId) if you need the last-known state of a deleted entity.
A physical practice site, clinic, or virtual care location within an account. Locations have their own addresses, staff assignments, schedules, and operational settings. Appointments, forms, and many operations are location-scoped. Each location has timezone information critical for accurate scheduling. Locations may have an optional storeId that partners can use to correlate Lobbie locations with their own systems.
A scheduled visit between a patient and practitioner at a specific location and time. Appointments track the complete visit lifecycle from initial scheduling through check-in, the visit itself, and completion or cancellation. Each appointment links to an appointment type, patient, practitioner, and location.
A template defining a schedulable service, including duration, telehealth settings, display color, and patient-facing description/instructions. Examples: "New Patient Consultation" (30 min), "Follow-up Visit" (15 min), "Telehealth Checkup" (20 min). Appointment types determine what services patients can book and how long they're scheduled.
A permission set defining capabilities and access levels for staff members. Role groups control what actions staff can perform within the system, from standard user access to full administrative privileges. Staff members can have multiple role groups assigned, combining permissions as needed.
A reusable form design defining structure, fields, validation rules, and conditional logic. Templates are created once and used to generate form instances for patients. Form templates support versioning—when a template is modified, a new version is created while preserving previous versions for historical form submissions. Examples: "Patient Intake Form", "HIPAA Consent", "Medical History Questionnaire".
A single field or component within a form template that captures patient input or displays content. Element types include: text inputs, text areas, dropdowns, checkboxes, radio buttons, date pickers, signatures, initials, file uploads, phone numbers, email addresses, addresses, and specialized types like payment fields and body diagrams. Elements can be configured as required or optional, and can have visibility rules controlling who can view or edit them (patient only, staff only, or both).
A trackable record of a payment’s lifecycle, from initiation to final settlement. Each request tracks its status from creation through payment or expiration, whether triggered as a remote request sent to a patient (via email or SMS) or entered directly by staff for immediate processing.
A partner-fulfilled lab order, distinct from the Lab read view above: it carries a granular status lifecycle (SUBMITTED, ACCEPTED, REJECTED, PARTIALLY_RESULTED, RESULTED, CORRECTED, CANCELLED) and drives the pull-based round trip. A lab partner polls GET /lab-order, accepts each order — recording its lab-assigned thirdPartyLabId — or rejects it, then posts results back. The order is thin: patient and ordering provider are referenced by id only, resolved via /patient and /staff (batched through /patient/batch and /staff/batch). A lab order links to its Lab record by labId, and both resources expose the lab-assigned identifier under the same name, thirdPartyLabId.
Accepting (POST /lab-order/{labOrderId}/accept) and rejecting (POST /lab-order/{labOrderId}/reject) both act only on a SUBMITTED order and return the updated order. Requisition files are attached by the accept that moves the order, so send every requisitionFileIds entry with it. Re-accepting with the same thirdPartyLabId is a 200 no-op when its requisitionFileIds are omitted or already on the order, and re-rejecting is a 200 no-op.
| Status | When |
|---|---|
400 | The body is malformed, a required field is missing (thirdPartyLabId to accept, reason to reject), or reasonCode is not one of the published values |
409 | Accept only, with a code: THIRD_PARTY_LAB_ID_CONFLICT when the order is already accepted with a different thirdPartyLabId or the vendor already assigned that thirdPartyLabId to another order; REQUISITION_FILES_CONFLICT when a repeat accept names a requisition file the order does not already carry |
422 | The order is not SUBMITTED; thirdPartyLabId or reason is blank or too long; an errors entry lacks a property or message; or a requisitionFileIds entry is not a finalized requisition file that is free to attach |
A single discrete result delivered against a lab order: code, an optional orderCode naming the ordered test it is reported under, name, a string value (so numeric and qualitative results such as "4.700" or "Non-Reactive" are both representable), unit, an optional referenceRange whose low and high bounds are each independently nullable (open-ended ranges), a flag (HIGH, LOW, NORMAL, ABNORMAL), and a status (PRELIMINARY, FINAL, CORRECTED). A delivery may carry a PDF-only report with no analytes.
The analyte flag is the lab-order contract's abnormal-result vocabulary; the Test Component read view reports the same concept as abnormalFlag, value for value: HIGH ↔ ABOVE_NORMAL, LOW ↔ BELOW_NORMAL, NORMAL ↔ WITHIN_NORMAL, and ABNORMAL ↔ ABNORMAL (the qualitative abnormal with no numeric range). Likewise an analyte's referenceRange.low / .high name the same concept the Test Component read view exposes as rangeStart / rangeEnd — with a scope difference: the analyte's range is the interval the lab resolved for that one result at delivery time, while the Test Component's referenceRanges are the configured range table (qualified by age, sex, machine), so the two need not agree.
A delivery (POST /lab-order/{labOrderId}/results) carries analytes, resultFileIds, or both — a PDF-only report with no analytes is valid, a delivery with neither is a 422. The body's thirdPartyLabId must match the one recorded when the order was accepted, and the order must be in ACCEPTED, PARTIALLY_RESULTED, RESULTED or CORRECTED. Each analyte is attributed to exactly one ordered test. Send orderCode — recommended — with the code of the ordered test the analyte is reported under (as an HL7 OBX is reported under its OBR): it must be one of the order's tests, and the analyte's code must be that orderCode or a result code the lab's test compendium lists under it, otherwise the delivery is a 422. Without orderCode, an ordered test takes the analyte when the analyte's code is its code, or when the compendium lists the code under it — a panel may be reported under its components' result codes. A lab's order codes and result codes can share values that mean different analytes, so while another ordered test's compendium listing holds the code, the test whose own code it is takes it only if the compendium lists no result codes for that test or lists the code among them; a code more than one ordered test could take is a 422 naming it — send orderCode to say which. Only the compendium of the order's lab vendor is consulted; for a vendor with no compendium on file, an analyte's code must equal an ordered test's code. A CORRECTED analyte is valid only for a test that already has a delivered result under the same code to supersede; and a PRELIMINARY analyte is refused for a code that already has a FINAL or CORRECTED result on its test, from an earlier delivery or earlier in the same one — a preliminary value never follows the final one. A refused delivery records nothing. For each analyte code of each ordered test, the result that stands, and that the patient's chart shows, is the latest FINAL or CORRECTED one, else the latest PRELIMINARY one; it replaces the charted value, unit and flag outright, and a result delivered without a unit is charted with the unit the lab's compendium lists for its code under that test.
Every delivery carries a partner-supplied deliveryId identifying that specific delivery attempt (the same convention as Lobbie-Webhook-Event-Id). Re-POSTing a deliveryId the order has already recorded is a 200 no-op returning the current order, whatever has happened since — so a lost-response retry can never duplicate results or overwrite a later correction, which always carries its own deliveryId. Send corrections with a new deliveryId, never by replaying an old one.
The server derives the order's status from the delivery — the partner never sets it. A delivery that leaves some ordered tests unfinished lands on PARTIALLY_RESULTED; one that finishes every ordered test, or declares final: true (no further results are coming, whatever the per-test coverage), lands on RESULTED and stamps the order's resulted time; one that carries a CORRECTED analyte lands on CORRECTED, unless it also newly completes the order — completion wins, with the correction kept on the order's audit trail. After a mid-stream correction, the remaining final results still complete the order: CORRECTED moves to RESULTED once every ordered test is finished. An ordered test is finished by a FINAL or CORRECTED result — except a panel resulted under its components' codes (one whose compendium listing does not include its own code), which is never finished by the results it receives, since which of a panel's components a lab reports varies. An order holding such a panel completes only on a delivery with final: true: send final: true on the delivery that completes the order.
| Status | When |
|---|---|
400 | The body is malformed, thirdPartyLabId or deliveryId is missing, or an analyte flag or status is not one of the published values |
422 | thirdPartyLabId or deliveryId is blank or too long, or thirdPartyLabId does not match the one recorded at acceptance; the order is not in a state that takes results; the delivery carries neither analytes nor resultFileIds; an analyte is missing code, name, value or status, exceeds a length limit, matches no ordered test, or could result more than one; an analyte's orderCode is blank, too long, not one of the order's tests, or does not take the analyte's code; a CORRECTED analyte has no earlier result under its code to supersede; a PRELIMINARY analyte arrives for a code that already has a FINAL or CORRECTED result on its test; or a resultFileIds entry is not a finalized result file that is free to attach |
Requisition and result PDFs are exchanged by reference, never as bytes on the API. A partner requests an upload slot (POST /file), uploads the bytes directly to the returned single-use presigned S3 URL, finalizes the file so it is format- and size-validated (POST /file/{fileId}/finalize), then refers to it by id — requisitionFileIds when accepting an order, resultFileIds when delivering results.
The upload PUT must carry every header the URL's signature covers, which uploadHeaders lists in full:
| Header | Value |
|---|---|
Content-Type | the type the file is stored as, application/pdf |
Content-Length | the exact sizeBytes the slot was requested for |
If-None-Match | * |
A PUT that omits one of them, or sends a different value, is refused by S3 with SignatureDoesNotMatch; If-None-Match: * is also what makes the URL single-use, so replaying it answers 412 rather than overwriting validated bytes. Send the headers exactly as uploadHeaders gives them.
Finalizing a file that is already CLEAN or REJECTED returns it unchanged. A finalized CLEAN file must be referenced within 24 hours of finalizing. One that is not is discarded and reads as not found from then on; request a new slot and upload it again.
Files belong to the account, not to the integration that uploaded them: every integration with file access to the account can read and reference the same files.
| Status | When |
|---|---|
400 | The body is malformed, a field is missing, or kind is not one of the published values |
422 | The file name is blank, too long, or of a type the kind does not accept; contentType is not the type the file is stored as; sizeBytes is outside the kind's limit; or the account has too many files in flight. Finalizing before the bytes have landed is also a 422; the slot stays usable, so upload and finalize again |
502 | The upload URL could not be signed, or the uploaded bytes could not be read to check them. Nothing changed; retry |
A service category that can function as either a CRM-tracked sales pipeline or a simple organizational tag. When CRM-tracked, categories enable full pipeline tracking through status stages with dashboard visibility. When not CRM-tracked, categories function as simple groupings for billing, inventory, or reporting. Examples of CRM categories: "Men's Health - Testosterone", "Weight Loss - Semaglutide". Examples of simple categories: "Lab Supplies", "Office Visits".
A patient's enrollment in a specific service category with CRM status tracking. Represents the link between a patient and a category, tracking their progress through the associated CRM pipeline. Appears as an "Opportunity" in CRM dashboards. Tracks status transitions with a complete audit trail.
A reusable CRM workflow pipeline (sales/marketing funnel) that defines the customer journey stages. Think of it as the sales funnel template—the workflow (HOW you sell) is separated from the product (WHAT you sell). Multiple categories often share the same sales process. Examples: "Digital Marketing Funnel" with stages like Cold Lead → Engaged → Sales Qualified → Closed Won; "Patient Care Journey" with stages like Intake → Insurance Verified → In Treatment → Complete.
A verification session for authenticating patient identity through email and/or SMS code verification. Used for patient portal authentication, self-scheduling verification, and secure access to patient data. Each session generates a secure token and tracks which contact methods have been verified.