IDOFO API
Put an AI front desk inside the software your customers already use.
IDOFO is not only an app. An EHR, a CRM, a salon or field-service platform can call the same services the IDOFO web app calls: check real availability, book, capture leads, take messages, read call summaries. Your customers keep the system they know.
Quick start
Three calls: find out who you are, ask for real availability, book it. The key decides the account — there is no tenant parameter anywhere in the API.
# 1. Check the key and see what it can do
curl https://localhost:3000/api/v1/me \
-H "Authorization: Bearer idofo_live_ak_xxx.yyy"
# 2. Ask for real open times on a specific day
curl "http://localhost:3000/api/v1/availability?date=2026-03-16" \
-H "Authorization: Bearer idofo_live_ak_xxx.yyy"
# 3. Book one of the times that came back
curl -X POST http://localhost:3000/api/v1/appointments \
-H "Authorization: Bearer idofo_live_ak_xxx.yyy" \
-H "Content-Type: application/json" \
-d '{"startsAt":"2026-03-16T14:00:00.000Z","durationMinutes":30,"title":"Consultation"}'const IDOFO_KEY = process.env.IDOFO_API_KEY!;
const base = 'http://localhost:3000/api/v1';
async function idofo<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(base + path, {
...init,
headers: {
Authorization: `Bearer ${IDOFO_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
return res.json() as Promise<T>;
}
const { data: slots } = await idofo<{ data: { startsAt: string; label: string }[] }>(
'/availability?date=2026-03-16',
);
if (slots.length) {
await idofo('/appointments', {
method: 'POST',
body: JSON.stringify({ startsAt: slots[0].startsAt, durationMinutes: 30, title: 'Consultation' }),
});
}Authentication
Send the key as a bearer token: Authorization: Bearer idofo_live_<id>.<secret>. The header x-idofo-key works too, for systems that reserve the Authorization header.
- A key belongs to exactly one account and cannot be pointed at another.
- Only a SHA-256 hash of the secret is stored — IDOFO cannot show you the key again.
- Keys carry scopes; give each integration the least it needs.
- Each key has a per-minute rate limit (120 by default). Exceeding it returns
429. - Rotating a key issues a replacement and revokes the old one in a single step.
Endpoints
All paths are under /api/v1. Every response is JSON.
| Method | Path | Scope |
|---|---|---|
| GET | /me | assistants.read |
| GET | /assistants | assistants.read |
| PATCH | /assistants/:id | assistants.write |
| GET | /contacts | contacts.read |
| POST | /contacts | contacts.write |
| GET | /contacts/:id | contacts.read |
| PATCH | /contacts/:id | contacts.write |
| GET | /availability?date=YYYY-MM-DD | appointments.read |
| GET | /appointments | appointments.read |
| POST | /appointments | appointments.write |
| GET | /appointments/:id | appointments.read |
| POST | /appointments/:id/reschedule | appointments.write |
| POST | /appointments/:id/cancel | appointments.write |
| GET | /tasks | tasks.read |
| POST | /tasks | tasks.write |
| PATCH | /tasks/:id | tasks.write |
| GET | /messages | messages.read |
| POST | /messages | messages.write |
| GET | /leads | leads.read |
| POST | /leads | leads.write |
| PATCH | /leads/:id | leads.write |
| GET | /calls | calls.read |
| GET | /calls/:id | calls.read |
| GET | /calls/:id/transcript | transcripts.read |
| GET | /business | business.read |
| PATCH | /business | business.write |
| GET | /services | business.read |
| POST | /services | business.write |
| GET | /knowledge | knowledge.read |
| POST | /knowledge | knowledge.write |
| POST | /webhooks | webhooks.write |
| POST | /assistant/conversations | assistants.write |
| POST | /assistant/conversations/:id/turns | assistants.write |
| POST | /assistant/conversations/:id/end | assistants.write |
| GET | /account/export | account.export |
Booking flow
Never construct a time yourself. Ask /availability, offer what it returns, then book one of those instants. The booking endpoint re-checks the slot inside a transaction, so two integrations racing for the same time produce one booking and one 409 conflict — never a double booking.
// Availability is computed from opening hours, staff schedules,
// existing appointments, blocked time and holiday closures.
GET /api/v1/availability?date=2026-03-16&serviceId=svc_123
{
"date": "2026-03-16",
"timezone": "America/Chicago",
"data": [
{ "startsAt": "2026-03-16T13:00:00.000Z", "endsAt": "2026-03-16T14:00:00.000Z", "label": "8:00 AM", "staffId": null },
{ "startsAt": "2026-03-16T15:30:00.000Z", "endsAt": "2026-03-16T16:30:00.000Z", "label": "10:30 AM", "staffId": null }
]
}Talking to the assistant
You can drive a conversation from your own product — a chat widget, an inbound message, an IVR — and get the same behaviour a phone call gets, including real bookings and message taking.
POST /api/v1/assistant/conversations
{ "callerName": "Helen Marsh", "callerNumber": "+15125551201" }
-> { "callId": "cal_...", "greeting": "Thanks for calling ...", "assistant": "Ava" }
POST /api/v1/assistant/conversations/cal_.../turns
{ "message": "How much is drain cleaning, and can someone come Friday?" }
-> {
"reply": "Drain cleaning is $149 and takes about 60 minutes. On Friday I have 8:00 AM, 10:30 AM and 1:00 PM open.",
"toolsUsed": ["get_prices", "get_available_appointments"],
"transferred": false,
"needsAttention": false
}
POST /api/v1/assistant/conversations/cal_.../end
-> { "summary": "...", "outcome": "appointment_booked", "durationSeconds": 92 }toolsUsed tells you exactly which backend tools produced the answer. Every fact in reply came from one of those results, not from a model's memory.
Webhooks
Register an endpoint and IDOFO posts signed events to it.
| Event | Meaning |
|---|---|
| call.started | A call reached the assistant and a session was created. |
| call.completed | A call ended. Payload includes summary, outcome and any records created. |
| appointment.created | An appointment was booked (by a person, the assistant or the API). |
| appointment.rescheduled | An existing appointment moved to a new time. |
| appointment.canceled | An appointment was canceled. |
| appointment.completed | Somebody recorded that an appointment went ahead. |
| appointment.no_show | Somebody recorded that the customer did not turn up. |
| lead.created | A new lead was captured. |
| lead.updated | A lead changed status or details. |
| message.created | The assistant took a message for a human. |
| task.created | A task was created. |
| task.completed | A task was marked done. |
| contact.created | A new contact record was created. |
| needs_attention.created | Something needs a human: unanswered question, failed booking, upset caller, and similar. |
| assistant.updated | Assistant configuration changed. |
| phone.number_claimed | A phone number was connected to this account. |
| phone.went_live | IDOFO started answering real calls for this account. |
| calendar.connected | An outside calendar account was connected. |
| calendar.disconnected | An outside calendar account was disconnected. |
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() - Number(parts.t)) > 300_000) return false; // reject replays
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Retries back off at 30s, 2m, 10m, 1h and 6h, then the delivery is marked failed and kept for inspection. Payloads carry identifiers and counts, never message bodies or transcripts — fetch those through the API with your key so the permission check stays on our side.
Errors
{ "error": { "code": "not_found", "message": "Appointment not found." } }| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A field is missing or malformed. |
| 401 | unauthenticated | Missing, unknown, wrong or revoked key. |
| 402 | entitlement_required | The account’s plan does not include this feature. |
| 402 | limit_exceeded | A plan limit has been reached. |
| 403 | forbidden | The key lacks the required scope. |
| 404 | not_found | No such record in this account. |
| 409 | conflict | The time was taken while you were booking it. |
| 429 | rate_limited | Too many requests for this key this minute. |
| 503 | provider_unavailable | An outside provider is unavailable. |
Permissions and isolation
Scopes map onto the same permissions the web app uses. The full list:
assistants.readassistants.writecontacts.readcontacts.writeappointments.readappointments.writecalls.readtranscripts.readtasks.readtasks.writemessages.readmessages.writeleads.readleads.writeknowledge.readknowledge.writebusiness.readbusiness.writewebhooks.writeaccount.export
Isolation is structural, not a filter applied at the end. Every request resolves the key to exactly one account, then builds a tenant-bound data handle; there is no code path that reads across accounts. A record id from another account returns the same 404 as an id that never existed, so ids cannot be probed.
This is verified by an automated suite that actively tries to cross the boundary — swapped ids, forged tenant fields, one account's key aimed at another's data. Results are in SECURITY_TEST_REPORT.md.
Sandbox mode
Create a key in test mode (idofo_test_…) for development. It behaves identically, so you can build against it, and the beta itself runs entirely on local mock providers: no carrier, no model vendor and no card is needed to integrate end to end.
Embedding IDOFO
Three levels, in rising order of effort:
- API only. Your product keeps its own interface and calls IDOFO for availability, booking, messages and call data. Nothing of ours appears in your UI.
- Embedded views. Drop IDOFO's inbox, call history or scheduling into an iframe or a panel inside your app.
- White label. Your brand, your domain, IDOFO underneath. Available as an add-on.
Example: an EHR or SaaS integration
Say you run scheduling software for clinics or a field-service platform for contractors. Your customers miss calls, and building a voice product is not your business. A typical integration:
- Create one IDOFO account per customer of yours, and one API key per account.
- Push their services, prices, hours and staff into IDOFO with
PATCH /business,POST /services,POST /knowledge— so the assistant answers with facts from your system. - Register a webhook for
appointment.created,lead.createdandneeds_attention.created. - When the webhook fires, fetch the detail with that customer's key and write it into your own tables.
- Show call summaries in your own interface using
GET /calls.
await idofo('/business', {
method: 'PATCH',
body: JSON.stringify({
legalName: clinic.name,
category: 'Medical office',
cancellationPolicy: clinic.policies.cancellation,
afterHoursRule: 'Take a message. Escalate anything describing chest pain or bleeding.',
}),
});
for (const service of clinic.services) {
await idofo('/services', {
method: 'POST',
body: JSON.stringify({
name: service.name,
price: service.priceUsd, // omit to have the assistant say "quoted individually"
durationMinutes: service.minutes,
}),
});
}
await idofo('/webhooks', {
method: 'POST',
body: JSON.stringify({
url: 'https://your-app.example.com/idofo/events',
events: ['appointment.created', 'lead.created', 'needs_attention.created'],
}),
});