# Mediflo External API v1 > Headless configuration and operation of Mediflo voice bots. > Base URL: https://app.mediflo.ai/api/external/v1 > Machine-readable contract: ./openapi.json GENERATED FILE — produced by `pnpm --filter portal openapi:generate`. Edit the zod schemas and route registry, not this file. ## Authentication Send `Authorization: Bearer mflo_`. Keys are tenant-scoped and carry an explicit scope list; a route you lack the scope for returns 403 `API_KEY_INSUFFICIENT_SCOPE` with the required and available scopes in `details`. Grantable scopes: tasks.read, tasks.write, users.read, tags.read, calls.read, thulium.initialize-call, contact-requests.read, contact-requests.write, bots.read, bots.write, knowledge.read, knowledge.write, locations.read, locations.write, phone-numbers.read, model-catalog.read, webhooks.manage, webhook-payloads.read, bot-calls.read, bot-calls.write, tickets.read, tickets.write, scheduling.read, scheduling.book, scheduling.modify, scheduling.manage, patients.read, contacts.lookup, analytics.read, trunks.read, trunks.write, ivr-flows.read, ivr-flows.write, queues.read, queues.write, cases.read, cases.write. Wildcards `.*`, `*` and `*.*` are accepted at key creation. Some scopes are opt-in only and NO wildcard grants them — not `*`, not `*.*`, not the resource wildcard. A key must name them verbatim or the route answers 403. Today that is: webhook-payloads.read, bot-calls.write, scheduling.book, scheduling.modify, scheduling.manage, patients.read, contacts.lookup, trunks.read, trunks.write, ivr-flows.read, ivr-flows.write, queues.read, queues.write, cases.read, cases.write. They cover content that may include personal data, including sensitive data, so inheriting them from a broad grant issued before the scope existed is precisely what they prevent. ## Conventions - Responses: `{ data }` for a single object, `{ data, pagination }` for lists. - Pagination: `{ page, limit, total, totalPages }`; query with `?page=&limit=` (limit max 100). - Errors: `{ error: { code, message, details? } }`. Branch on `code`. - Validation: 422 `VALIDATION_ERROR`, `details` is an array of `{ field, message }`. - Rate limits: 429 with `Retry-After` (seconds). - Timestamps: ISO 8601 UTC strings. - Concurrency: bot config uses `expectedUpdatedAt`; workflows use `expectedVersion`. A mismatch is 409 with the current value. ## Getting a bot live (the happy path) 1. `POST /locations` — a bot cannot exist without one. 2. `POST /bots` with that `locationId`. The bot is created with Mediflo's recommended models, turn-taking mode and extraction model already applied — the models are configured by Mediflo, not through this API. 3. `GET /model-catalog` — `recommendedDefaults` carries the turn-taking and knowledge-retrieval settings step 2 applied. 4. `PATCH /bots/{botId}/config` — prompt, greeting, guardrails, tools. `agentPromptText` is required before step 8 will publish (`remarksOutputSchemaJson` is optional — omit it to extract only the always-on system analysis fields); the model settings publish also needs were filled by step 2. A change to a model setting is refused with 403 `BOT_MODEL_CONFIG_STAFF_ONLY`. 5. `POST /bots/{botId}/workflows` with `template: "visit-confirmation"` (or `"registration"`, `"blank"`). 6. Edit the definition, checking it with `POST /bot-workflows/validate` until `valid` is true, then `PUT /bot-workflows/{workflowId}`. 7. `POST /bot-workflows/{workflowId}/activate`. 8. `POST /bots/{botId}/config/publish`. 9. Inbound: `POST /phone-numbers/{id}/attach-bot`. Outbound: `PATCH /bots/{botId}` with `phoneNumberId`. 10. `GET /bots/{botId}/readiness` — work through any `ok: false` check. Read `ready` for "can take a call" and `readyForOutbound` for "can place a call"; an inbound-only bot is ready without being outbound-ready. ## Endpoints Scope required is shown in brackets. The tasks, calls, users, tags and contact-requests families predate this API and are unchanged here; the rendered reference for every endpoint (Polish) is https://docs.mediflo.ai/api/. Running headless? `GET /bot-sessions` plus `/telemetry` is your debugging loop, and `POST /tickets` with `botSessionIds` is how you hand a broken call to Mediflo support. Telemetry never contains conversation content — pull `/transcript` separately, and only when you need it. Placing a call from your own system? `POST /bot-sessions` (scope `bot-calls.write`) dials the number NOW with the bot you name — no scheduling. `idempotencyKey` is required: the same key never dials twice (a replay answers 200 with the existing session instead of 201). Calls are only placed 08:00–20:00 in the tenant's timezone (`OUTSIDE_CALL_WINDOW`), the bot needs an outbound number and a published config, and `context` (`reason`, `message`, `appointment`, `variables`) is rendered into the bot's prompt as `{{reason}}`, `{{message}}`, `{{appointment_date}}`, … The outcome arrives on the `bot_call.completed` webhook and on `GET /bot-sessions/{dispatchId}`. ### Bots - GET /bots — List bots [bots.read] - POST /bots — Create a bot [bots.write] - GET /bots/{botId} — Get a bot [bots.read] - PATCH /bots/{botId} — Update a bot [bots.write] - GET /bots/{botId}/readiness — Check whether a bot can take calls [bots.read] ### Bot configuration - GET /bots/{botId}/config — Get the draft configuration [bots.read] - PATCH /bots/{botId}/config — Edit the draft configuration [bots.write] - POST /bots/{botId}/config/publish — Publish the draft as an immutable snapshot [bots.write] - GET /bots/{botId}/config/snapshots — List published configuration versions [bots.read] - POST /bots/{botId}/config/rollback — Roll back to a published snapshot [bots.write] ### Workflows - GET /bots/{botId}/workflows — List a bot’s conversation workflows [bots.read] - POST /bots/{botId}/workflows — Create a conversation workflow [bots.write] - GET /bot-workflows/{workflowId} — Get a workflow with its definition [bots.read] - PUT /bot-workflows/{workflowId} — Replace a workflow [bots.write] - POST /bot-workflows/{workflowId}/activate — Make this the bot’s active workflow [bots.write] - POST /bot-workflows/{workflowId}/deactivate — Return the bot to prompt-only mode [bots.write] - DELETE /bot-workflows/{workflowId} — Soft-delete a workflow [bots.write] - POST /bot-workflows/validate — Validate a workflow definition without saving [bots.read] ### Procedures - GET /bots/{botId}/procedures — List a bot’s procedures [bots.read] - POST /bots/{botId}/procedures — Create a procedure [bots.write] - POST /bots/{botId}/procedures/reorder — Reorder a bot’s procedures [bots.write] - GET /bot-procedures/{procedureId} — Get a procedure [bots.read] - PATCH /bot-procedures/{procedureId} — Edit a procedure [bots.write] - DELETE /bot-procedures/{procedureId} — Soft-delete a procedure [bots.write] ### Knowledge - GET /bots/{botId}/knowledge — Get the bot’s knowledge-base settings [knowledge.read] - PATCH /bots/{botId}/knowledge — Update the knowledge-base settings [knowledge.write] - GET /bots/{botId}/knowledge/sources — List knowledge sources [knowledge.read] - POST /bots/{botId}/knowledge/sources — Create a text or structured knowledge source [knowledge.write] - GET /bots/{botId}/knowledge/sources/{sourceId} — Get a knowledge source with its content [knowledge.read] - PATCH /bots/{botId}/knowledge/sources/{sourceId} — Update a knowledge source [knowledge.write] - DELETE /bots/{botId}/knowledge/sources/{sourceId} — Delete a knowledge source [knowledge.write] ### Locations - GET /locations — List locations [locations.read] - POST /locations — Create a location [locations.write] - GET /locations/{locationId} — Get a location [locations.read] - PATCH /locations/{locationId} — Update a location [locations.write] ### Phone numbers - GET /phone-numbers — List phone numbers [phone-numbers.read] - POST /phone-numbers/{phoneNumberId}/attach-bot — Route an inbound number to a bot [bots.write] ### Vocabularies - GET /model-catalog — Recommended bot settings [model-catalog.read] - GET /meta/event-types — List webhook event types and payload blocks [(any valid key)] ### Webhooks - GET /webhook-endpoints — List webhook endpoints [webhooks.manage] - POST /webhook-endpoints — Create a webhook endpoint [webhooks.manage] - GET /webhook-endpoints/{endpointId} — Get a webhook endpoint [webhooks.manage] - PATCH /webhook-endpoints/{endpointId} — Update a webhook endpoint [webhooks.manage] - DELETE /webhook-endpoints/{endpointId} — Delete a webhook endpoint [webhooks.manage] - POST /webhook-endpoints/{endpointId}/rotate-secret — Rotate an endpoint signing secret [webhooks.manage] - POST /webhook-endpoints/{endpointId}/test — Send a test event [webhooks.manage] - GET /webhook-deliveries — List webhook deliveries [webhooks.manage] - GET /webhook-deliveries/{deliveryId} — Get a webhook delivery [webhooks.manage] - GET /webhook-deliveries/{deliveryId}/payload — Get the payload a delivery carried [webhook-payloads.read] - POST /webhook-deliveries/{deliveryId}/redeliver — Redeliver a webhook delivery [webhooks.manage] ### Bot sessions - GET /bot-sessions — List bot sessions [bot-calls.read] - POST /bot-sessions — Place an outbound bot call [bot-calls.write] - GET /bot-sessions/{dispatchId} — Get a bot session [bot-calls.read] - GET /bot-sessions/{dispatchId}/telemetry — Get per-call diagnostics [bot-calls.read] - GET /bot-sessions/{dispatchId}/transcript — Get the conversation transcript [bot-calls.read] - GET /bot-sessions/{dispatchId}/recording — Get a short-lived URL to the call recording [bot-calls.read] ### Support tickets - GET /tickets — List support tickets [tickets.read] - POST /tickets — File a support ticket with Mediflo [tickets.write] - GET /tickets/{ticketId} — Get a support ticket [tickets.read] - POST /tickets/{ticketId}/sessions — Attach more bot sessions to a ticket [tickets.write] ### Cases - GET /cases — List customer cases [cases.read] - POST /cases — Create a customer case [cases.write] - GET /cases/{caseId} — Get a customer case [cases.read] - PATCH /cases/{caseId} — Update a customer case [cases.write] - GET /cases/{caseId}/comments — List a case’s internal notes [cases.read] - POST /cases/{caseId}/comments — Add an internal note to a case [cases.write] - POST /cases/{caseId}/links — Link a call or conversation to a case [cases.write] - DELETE /cases/{caseId}/links/{linkId} — Remove a link from a case [cases.write] ### Scheduling - GET /scheduling/appointment-types — List bookable visit types [scheduling.read] - GET /scheduling/specialists — List bookable specialists [scheduling.read] - GET /scheduling/booking-requirements — Patient fields, notice policies, sites and readiness [scheduling.read] - GET /scheduling/availability — Free slots for a visit type [scheduling.read] - POST /scheduling/holds — Hold a slot for a few minutes [scheduling.book] - DELETE /scheduling/holds/{holdId} — Release a hold [scheduling.book] - POST /scheduling/appointments — Book an appointment [scheduling.book] - GET /scheduling/booking-operations/{operationId} — Outcome of an unresolved booking [scheduling.read] - GET /scheduling/appointments — Upcoming appointments for a phone number (deprecated) [scheduling.read] - POST /scheduling/appointments/search — Upcoming appointments for a phone number [scheduling.read] - POST /scheduling/appointment-actions/reschedule — Move the caller's own appointment [scheduling.modify] - POST /scheduling/appointment-actions/cancel — Cancel the caller's own appointment [scheduling.modify] - POST /scheduling/appointments/{appointmentId}/reschedule — Move any appointment (staff and partner keys) [scheduling.manage] - POST /scheduling/appointments/{appointmentId}/cancel — Cancel any appointment (staff and partner keys) [scheduling.manage] - POST /scheduling/callback-requests — Ask the clinic to call the patient back [scheduling.read] ### Patients - GET /patients/lookup — Find a patient by phone number (deprecated) [patients.read] - POST /patients/lookup — Find a patient by phone number [patients.read] ## Not part of this API (by design) - Tenant creation — handled by Mediflo during onboarding. - SIP trunk and phone-number provisioning — Portal only, set up together with Mediflo. This API binds an already-provisioned number. - Model catalog management — internal; the read-only pick list above is the only touchpoint. - Deleting a bot — bots are hard-deleted, so removal stays in the Portal. - Uploading knowledge FILES — this API takes JSON text/structured sources. ## Webhook events Endpoints are configured through the webhooks management API; the vocabulary below is also served live at `GET /meta/event-types`, where each entry carries a `sampleEnvelope` — the complete wire shape with an obviously-fake, non-PHI payload. Read that endpoint before coding a consumer; it is generated from the same module the producers are validated against, so it cannot describe a shape we do not send. - ping — Test event sent by the endpoint "test" action. Carries no call or task data. - bot_call.completed — A bot call reached a terminal state (completed, no_answer, voicemail, failed or stuck). PHI blocks: phoneNumbers, outcome, transcript, contact. - call.completed — A human call reached a terminal state. PHI blocks: phoneNumbers, outcome, transcript, contact. - call.updated — A previously reported call was corrected — e.g. the voicemail reclassifier changed a NO_ANSWER verdict. PHI blocks: phoneNumbers, outcome, transcript, contact. - call.transcript.ready — Transcription finished for a human call. PHI blocks: phoneNumbers, outcome, transcript, contact. - call.analysis.completed — Post-call analysis finished for a call. `insights.profile` names the taxonomy the insight values come from (medical, commercial, b2b or b2c), or is null when it is not known — for example for calls analysed before profiles existed. PHI blocks: phoneNumbers, outcome, transcript, contact. - task.created — A task was created, including AI-suggested tasks (the AI rationale is never included). - task.updated — A task changed — status, assignee, priority or due date. - task.deleted — A task was deleted. - task.suggestion.resolved — An AI-suggested task was accepted, rejected or expired. - case.created — A customer case was created. Offered only to organisations with customer cases enabled. - case.updated — A customer case changed — status, assignee, priority, classification, due date, linked records or a new internal note; `changed` lists which (note content is never included). - case.deleted — A customer case was deleted. - webhook_endpoint.auto_disabled — An endpoint was auto-disabled after a sustained delivery failure window. Payload blocks carrying personal data, including sensitive data: phoneNumbers, outcome, transcript, contact. ### Receiving a delivery `POST`, `Content-Type: application/json`, body = the envelope `{ id, type, apiVersion, occurredAt, data }`. Headers: - `Mediflo-Event-Id` — the envelope id. Delivery is at-least-once, so this is your idempotency key. - `Mediflo-Event-Type` — the event type, so you can route without parsing. - `Mediflo-Signature` — see below. - `Mediflo-Event-Test` — `true` ONLY on a delivery triggered from `POST /webhook-endpoints/{id}/test`. Absent (not `false`) on real events. Drop these before production processing. Delivery contract: at-least-once, no ordering guarantee (compare `data.revision`, comparable only within one resource), 2xx within 10 s or it counts as a failure, 3xx is a failure and is never followed, retries at 1 m / 5 m / 30 m / 2 h / 8 h / 24 h then `EXHAUSTED`, and an endpoint with 20 consecutive failures and no success for 72 h is `AUTO_DISABLED`. ### Sending yourself a test event `POST /webhook-endpoints/{id}/test` with an optional `{ "eventType": "" }`. It travels the real pipeline — real signature, real delivery row — and sends that type's `sampleEnvelope.data` as its payload, with `Mediflo-Event-Test: true`. Omit the body for a plain `ping`. ### Verifying a webhook signature Each delivery carries `Mediflo-Signature: t=,v1=` where `v1 = HMAC_SHA256(secret, ".")`. Sign the RAW bytes; re-serializing parsed JSON does not reproduce the digest. Reject timestamps outside ±300 seconds. During secret rotation two `v1` entries are sent for 24 h; accept if EITHER matches. Validate the hex before comparing: `Buffer.from('zz', 'hex')` truncates silently and `timingSafeEqual` throws on a length mismatch, so a naive verifier answers 500 to a garbage header — which the retry ladder then hammers for 35 hours. ```ts import { createHmac, timingSafeEqual } from 'node:crypto' /** * Verify a Mediflo webhook signature. * * Header shape: `t=,v1=[,v1=]` * where `v1 = HMAC_SHA256(secret, ".")`. * * `rawBody` MUST be the exact bytes we sent. Re-serializing parsed JSON does * not reproduce the digest — in Express, use * `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`. * * Pass BOTH secrets during a rotation window (or just the current one; the * header carries a `v1=` entry per active secret and any match is valid). */ export function verifyMedifloSignature( header: string | undefined, rawBody: string | Buffer, secrets: string[], toleranceSeconds = 300 ): boolean { if (!header || secrets.length === 0) return false let timestamp: number | null = null const presented: string[] = [] for (const part of header.split(',')) { const trimmed = part.trim() const eq = trimmed.indexOf('=') if (eq <= 0) continue const key = trimmed.slice(0, eq) const value = trimmed.slice(eq + 1) if (key === 't') { if (!/^\d+$/.test(value)) return false timestamp = Number(value) } else if (key === 'v1') { // More than one entry appears during a 24 h secret rotation. presented.push(value) } } if (timestamp === null || presented.length === 0) return false // Replay protection: the timestamp is INSIDE the signed string, so an // attacker cannot move it forward without invalidating every digest. const now = Math.floor(Date.now() / 1000) if (Math.abs(now - timestamp) > toleranceSeconds) return false for (const secret of secrets) { const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') for (const candidate of presented) { // Validate the hex BEFORE comparing. Buffer.from('zz', 'hex') truncates // silently, and timingSafeEqual throws RangeError on a length mismatch — // so without these two guards a malformed header crashes the handler // instead of returning false. const normalized = candidate.trim().toLowerCase() if (!/^[0-9a-f]+$/.test(normalized)) continue if (normalized.length !== expected.length) continue if ( timingSafeEqual( Buffer.from(normalized, 'hex'), Buffer.from(expected, 'hex') ) ) { return true } } } return false } ```