Developers
REST API and webhooks.
Read and manage everything on your agency account, and push real-time call and booking events into any outside system. A Scale-plan feature, with API keys, scoped access, and signed webhooks. Every endpoint and event below lists a short "when you would use it", plus copyable curl, worked JSON, and named integration recipes, so you (or an AI assistant) can wire up an integration fast.
Plan
API keys, the REST API, and webhooks are a Scale plan feature. Call-center agencies get them on any plan, and agencies on a free trial are treated as Scale while trialing. Below that, requests return upgrade_required.
Base URL
https://urchin-app-bqb4i.ondigitalocean.app/api/v1All paths below are relative to this base.
Authentication
Every request authenticates with an API key, created in the agency dashboard under Settings → API. The full key is shown once at creation; only a SHA-256 hash and a short prefix are stored, so a lost key is revoked and replaced, never recovered. Keys are agency-scoped.
Authorization: Bearer YOUR_API_KEY # or, equivalently: x-api-key: YOUR_API_KEY
readDefault. All GET endpoints.
read_writePOST, PATCH, PUT, DELETE. A write with a read key returns 403 insufficient_scope.
Your first request
curl https://urchin-app-bqb4i.ondigitalocean.app/api/v1/account \ -H "Authorization: Bearer YOUR_API_KEY"
Returns the authenticated agency: id, name, plan_type, subscription_status, and the key's scope.
Conventions
Errors
{ "error": { "type": "insufficient_scope", "message": "This API key is read-only." } }Every error uses that envelope. Types you may see:
401authentication_errorMissing, invalid, or revoked key.403insufficient_scopeWrite attempted with a read-only key.403upgrade_requiredAccount is below the Scale plan.403account_suspendedAgency account is suspended.404not_foundNo such object, or not owned by your agency.400invalid_requestBad or missing fields in the request.429rate_limit_exceededOver 120 requests/min on this key.501not_implementedEndpoint exists but is not available yet (e.g. DELETE /clients).400/500api_errorA query or internal error.Pagination
List endpoints take limit (1–100, default 25) and cursor. Pass the returned cursor back to page.
{ "data": [ ... ], "has_more": true, "next_cursor": "2026-10-20T14:03:00.000Z" }Single objects return directly. Only allowlisted public fields are returned; internal IDs and secrets never leave the API. (Numbers, usage, analytics, and the event catalog return a plain { data } or object, not a cursor page.)
Rate limits
Each API key is limited to 120 requests per minute. Every response carries the current budget, and going over returns 429 rate_limit_exceeded with a Retry-After header.
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 118 X-RateLimit-Reset: 1793114400
For a large nightly sync, page with the max limit=100 and, if you hit a 429, wait the window out using Retry-After rather than hammering.
Clients
/clientsList clients (paginated). Filter by status or industry.WhenAn agency pulls its whole client roster into its own CRM or billing dashboard each night, so a plumber it onboarded in VoiceAI shows up in its system with no manual entry. Filter ?status=active to skip clients still provisioning, or ?industry=hvac to sync one vertical at a time./clientsread_writeCreate and auto-provision a client (assistant + phone number). Returns 202 with a provisioning job id.WhenA new HVAC company signs up on the agency’s own website and this creates the client and provisions its AI number in the background, no logging into VoiceAI. The call returns a job id immediately; poll provisioning-status to show a live "setting up your receptionist" state instead of freezing the signup form./clients/:idOne client.WhenShow one client's live details, provisioned AI number, plan, and status inside the agency's own dashboard, so they never log into VoiceAI to check a single account. Good for a per-client detail page./clients/:idread_writeUpdate a whitelist of client fields (name, city, notification phone, post-call SMS, etc.).WhenA restoration client updates their business name or after-hours notification number in the agency's portal, and this pushes it back to VoiceAI so the records stay in sync. Provisioning-owned fields (the number, assistant ids, billing) stay locked./clients/:idread_writeNot available yet. Returns 501 not_implemented.WhenDeletion has to tear down the paid phone number, the assistant, and billing cleanly, so for now offboard a client from the dashboard. A write path for this is coming./clients/:id/provisioning-status/:jobIdPoll a provisioning job after a create.WhenDrive the onboarding screen right after a create: "finding a local number... building your receptionist... ready." Stop polling when status is done, and read the provisioned phone number from the result to show the new client the number to forward their line to.Example: create a client
curl -X POST https://urchin-app-bqb4i.ondigitalocean.app/api/v1/clients \
-H "Authorization: Bearer YOUR_READ_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Dana",
"last_name": "Reyes",
"email": "dana@brightplumbing.com",
"phone": "+14045550199",
"business_name": "Bright Plumbing",
"industry": "plumbing",
"business_city": "Atlanta",
"business_state": "GA",
"plan_type": "starter"
}'// 202 Accepted, provisioning runs in the background
{ "client_id": "9f3b...", "job_id": "prov_8c21...", "status": "processing" }
// Then poll:
// GET /clients/9f3b.../provisioning-status/prov_8c21...
// -> { "id": "...", "status": "done", "result": { "phone_number": "+1..." }, "created_at": "..." }Receptionist & Knowledge base
/clients/:id/receptionistCurrent AI config: greeting, voice, human-handoff mode, transfer number, business hours.WhenShow a client's current greeting, voice, and after-hours transfer in the agency's own settings screen, so they can review how a restoration company's emergency line is set up without leaving their tool./clients/:id/receptionistread_writeUpdate the AI config. Takes effect on the next call, no rebuild.WhenA med spa wants a warmer greeting and calls sent to the front desk after 5pm; the agency changes it from their own panel and it lands on the very next call, no rebuild. Also how an agency bulk-applies one on-brand greeting across every client in a vertical./clients/:id/knowledge-baseThe client's knowledge base content.WhenExport what the AI knows about a plumbing client (hours, service area, pricing) so the agency can review or back it up before a big edit./clients/:id/knowledge-baseread_writeReplace the knowledge base content.WhenSync a junk-removal client's pricing and service list from the agency's own source of truth, so when they change their dump fees in one place the AI starts quoting the new number. Writing it rebuilds the assistant knowledge file in the background, so it is heavier than a receptionist edit.Calls
/callsList calls (paginated). Filter by client_id, since, until.WhenAn HVAC agency pulls every call each night so a missed-call lead becomes a morning follow-up task in their CRM. A restoration company syncs full transcripts into a warehouse to audit how the AI handled emergency water-damage calls. Walk pages with cursor and bound each run with ?since so you only fetch new calls./calls/:idOne call: outcome, captured customer details, summary, transcript, recording.WhenOpen one call's transcript, summary, and captured details in your app, for when a septic client asks "did the AI actually book that pumping job?" and the agency needs the full record. This fetch also resolves a fresh recording link./calls/:id/recordingA fresh, authenticated recording URL.WhenStored links expire, so call this the instant a user hits play, for example an agency QAing a salon's bookings by listening to the actual calls, or attaching a recording to a dispute.Example: list and page
curl https://urchin-app-bqb4i.ondigitalocean.app/api/v1/calls?limit=25 \ -H "Authorization: Bearer YOUR_API_KEY" # Next page: pass the cursor you got back curl "https://urchin-app-bqb4i.ondigitalocean.app/api/v1/calls?limit=25&cursor=2026-10-20T14:03:00.000Z" \ -H "Authorization: Bearer YOUR_API_KEY"
{
"data": [ { "id": "c1a2...", "client_id": "9f3b...", "caller_phone": "+14045551212", "... ": "..." } ],
"has_more": true,
"next_cursor": "2026-10-20T14:03:00.000Z"
}Appointments
/clients/:id/availabilityOpen slots for a client on a date. ?date=YYYY-MM-DD (&duration=30).WhenBefore booking, show a caller's real options, say a plumbing client's open 2-hour windows tomorrow, in your own flow or chatbot. Needs a connected calendar to read against; a client on no calendar returns 409 so you can fall back to just capturing the request./appointmentsList appointments. Filter by client_id, status, since, until.WhenPut a lawn-care client's upcoming jobs on an ops board, or reconcile what the AI booked against the crew's schedule every morning. Filter ?since/?until for a given week./appointments/:idOne appointment.WhenPull one booking's full details (customer, time, service, where it landed) when a client asks the agency to confirm a specific appointment./appointmentsread_writeCreate an appointment. Writes the real calendar event when the client has one connected.WhenBook from your own flow, not just the phone AI: a restoration company's website chatbot or the agency's intake form creates the appointment here. A Google-connected client gets a real calendar event (availability-checked); a client with no calendar gets a recorded booking so the call still succeeds. Fires appointment.booked so your reminders and CRM sync run either way./appointments/:idread_writeReschedule to a new date/time.WhenA customer calls back to move their HVAC tune-up to Thursday; reschedule here and the Google event moves with it (a record-only booking just updates). Fires appointment.rescheduled with the old time./appointments/:id/cancelread_writeCancel an appointment (kept for history).WhenA caller cancels their junk pickup; this frees the slot, removes the calendar event, and fires appointment.canceled so the agency's reminder sequence stops texting them. Optional body: reason.Appointments are created by the AI during calls and, now, directly through the API. How a write reaches a real calendar depends on what the client has connected. See Calendar booking.
Numbers · Usage · Analytics
/numbersProvisioned phone numbers across the agency's clients.WhenAudit which AI number every client runs on across the whole agency, and catch any client still stuck without a provisioned number./usageMinutes and records per client over a window or billing month.WhenBill your own clients from real minute usage: an agency pulls each HVAC client's monthly minutes to mark up and invoice. Pass ?billing_month=YYYY-MM for a clean monthly total, or ?since/?until for a custom window./analytics/callsCall aggregates: totals, minutes, spam, by status.WhenFeed call volume, spam counts, and outcomes into an agency reporting dashboard, so they can show a plumbing client "42 calls answered, 11 jobs booked" this month without exporting every call row.Webhook management
/eventsThe event catalog (array of event-type strings).WhenLet an AI assistant or a dynamic settings screen discover the exact event names before subscribing, instead of hardcoding a list that could drift./webhooksread_writeSubscribe a URL to events. Returns the signing secret once.WhenWire VoiceAI into Zapier, Make, n8n, GoHighLevel, or your own endpoint, e.g. an agency subscribes call.completed to drop every lead into its GoHighLevel workflow. The response includes a whsec_ secret exactly once, store it then, it is never shown again./webhooksList subscriptions.WhenSee every subscription plus each endpoint's last delivery and last error, so an agency can tell at a glance whether its CRM sync is healthy./webhooks/:idOne subscription.WhenCheck a single subscription config and health./webhooks/:idread_writeUpdate URL, events, or status (active / disabled).WhenMove your endpoint to a new URL or change which events you get without re-creating the subscription or minting a new secret. Set status disabled to pause delivery during maintenance, then active again./webhooks/:idread_writeDelete a subscription.WhenTurn a subscription off for good when an agency drops an integration./webhooks/:id/deliveriesDelivery log: status, attempts, response code, stored payload.WhenDebug why your endpoint missed an event, for when a restoration company says "the booking never hit our CRM." Each row shows the attempt count, your endpoint's response code, and the exact payload sent./webhooks/:id/pingread_writeSend a test ping event to the endpoint.WhenFire a test event to confirm your endpoint and signature check work before going live, so the first real customer call is not also your first test. The ping has type "ping", not a catalog event.Subscribe
curl -X POST https://urchin-app-bqb4i.ondigitalocean.app/api/v1/webhooks \
-H "Authorization: Bearer YOUR_READ_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/voiceai",
"events": ["call.completed", "appointment.booked"],
"description": "CRM sync"
}'// 201 Created, secret is shown ONCE, store it now
{
"id": "wh_7a9c...",
"url": "https://yourapp.com/hooks/voiceai",
"events": ["call.completed", "appointment.booked"],
"status": "active",
"description": "CRM sync",
"created_at": "2026-10-27T15:12:00.000Z",
"secret": "whsec_1f2e3d4c..."
}Use "events": ["*"] to subscribe to everything. The secret is returned only on this create call.
Event catalog
call.startedA call begins (live event; carries a call_session_id, not a calls row id).call.completedA call ends. Enriched with the full call (see payloads).call.transferredThe call was transferred to the business line. Enriched like call.completed.call.escalatedA call-center agent took over a live call (browser jump-in or their phone was rung).appointment.bookedAn appointment was created (by the AI on a call, or via POST /appointments).appointment.rescheduledAn appointment moved to a new time (via PATCH /appointments/:id). Carries previous_appointment_time.appointment.canceledAn appointment was canceled (via POST /appointments/:id/cancel). Carries cancel_reason.client.provisionedA new client finished provisioning and is live (self-signup or agency-added).client.provisioning_failedProvisioning failed for a client.client.trial_startedA free trial began (once per trial).client.payment_succeededA payment went through. The only event that confirms money moved; is_first_payment marks the trial-to-paid conversion.client.payment_failedA client's payment failed and will be retried.client.subscription_activatedCheckout completed. Not a payment signal: a card-on-file trial activates without a charge.client.trial_expiredA client trial expired.client.cancellation_scheduledA cancellation was requested for the end of the billing period.client.cancellation_revertedA scheduled cancellation was undone.client.canceledService ended (once per cancellation, with reason_code and the client’s stated reason).Envelope
{ "id": "evt_uuid", "type": "call.completed", "created_at": "...", "data": { ... } }Signature verification
Each delivery is signed. Three headers are sent:
X-VoiceAI-Signature: t=<unix_ts>,v1=<hex> X-VoiceAI-Event: call.completed X-VoiceAI-Delivery: <delivery_id>
where v1 = HMAC-SHA256(secret, t + "." + rawBody). Verify against the raw body before parsing JSON.
// Node, verify a delivery before trusting it
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(',').map(x => x.split('=')));
const expected = crypto.createHmac('sha256', secret)
.update(p.t + '.' + rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}
// Pass the exact raw request body, not the re-stringified JSON.Deliveries are logged and retried on failure: up to 6 attempts with a 1 / 5 / 30 / 120 / 360 / 720 minute backoff and a 10-second timeout, so your endpoint should return a 2xx quickly and do slow work after. Stored payloads keep a short-lived recording_url; use recording_api_url or GET /calls/:id/recording for a fresh one.
Payloads
call.completed and call.transferred carry the whole call, so you can ingest from the webhook alone with no follow-up GET.
{
"id": "evt_6b1f9c2a-...",
"type": "call.completed",
"created_at": "2026-10-27T15:42:11.904Z",
"data": {
"id": "a7c4e0d2-...",
"client_id": "9f3b1d88-...",
"caller_phone": "+14045551212",
"customer_name": "Marcus Hale",
"customer_phone": "+14045551212",
"customer_email": "marcus.hale@gmail.com",
"customer_address": "812 Oak Ridge Dr, Atlanta, GA",
"service_requested": "Water heater leaking, wants someone out today",
"urgency_level": "high",
"sentiment": "anxious",
"appointment_booked": true,
"appointment_time": "2026-10-27T21:00:00.000Z",
"ai_summary": "Caller has an active leak. Booked an emergency visit for 5pm.",
"summary": "Emergency water heater call, same-day visit booked.",
"transcript": "AI: Thanks for calling Bright Plumbing...",
"recording_url": "https://storage.vapi.ai/....mp3?token=... (short-lived)",
"recording_api_url": "https://urchin-app-bqb4i.ondigitalocean.app/api/v1/calls/a7c4e0d2-.../recording",
"ended_reason": "customer-ended-call",
"transfer_status": null,
"is_spam": false,
"spam_reason": null,
"call_language": "en",
"started_at": "2026-10-27T15:40:02.000Z",
"ended_at": "2026-10-27T15:42:10.000Z"
}
}appointment.booked
Fires on any create, whether from the AI on a call or POST /appointments. appointment.rescheduled and appointment.canceled carry the same shape (plus previous_appointment_time / cancel_reason). google_event_id and calendar_link are null for a record-only appointment.
{
"id": "evt_2d8a7f10-...",
"type": "appointment.booked",
"created_at": "2026-10-27T15:42:09.511Z",
"data": {
"id": "appt_5e9b...",
"client_id": "9f3b1d88-...",
"business_name": "Bright Plumbing",
"customer_name": "Marcus Hale",
"customer_phone": "+14045551212",
"appointment_time": "2026-10-27T21:00:00.000Z",
"end_time": "2026-10-27T22:00:00.000Z",
"duration_minutes": 60,
"timezone": "America/New_York",
"service_type": "Emergency water heater",
"staff_name": null,
"notes": "Active leak, caller will be home.",
"status": "confirmed",
"booking_source": "ai_receptionist",
"google_event_id": "abc123def456",
"google_calendar_id": "primary",
"calendar_link": "https://calendar.google.com/event?eid=...",
"created_at": "2026-10-27T15:42:09.000Z"
}
}Integration recipes
Schedule appointments via the API
availability, create, reschedule, cancel
1. Create a read_write key. 2. GET /clients/:id/availability?date=YYYY-MM-DD to show open slots. 3. POST /appointments with client_id, customer_name, date, time (+ service_type, duration_minutes). Google-connected client -> real calendar event; no calendar connected -> record-only appointment. Fires appointment.booked. 4. PATCH /appointments/:id to move it (appointment.rescheduled); POST /appointments/:id/cancel to cancel (appointment.canceled).
Sync calls to your CRM
GET /calls, scheduled pull
1. Create a read key. 2. On a schedule (hourly/nightly), GET /calls?since=<last run>&limit=100 3. Walk pages with next_cursor until has_more is false. 4. Upsert each call by its id into your CRM; store ended_at as the sync mark. Tip: the row already carries customer_name/phone/email, service_requested, summary and transcript, so no per-call follow-up is needed.
Real-time alerts via call.completed
Webhook, for Zapier / Make / your own endpoint
1. Create a read_write key. 2. POST /webhooks with events ["call.completed"] and your endpoint URL. Store the whsec_ secret from the response. 3. On each delivery: verify the signature against the raw body, then act, e.g. - create a CRM activity from data.summary + data.service_requested - text the owner if data.urgency_level is "high" or "emergency" - if data.appointment_booked is true, push it to your calendar 4. Return 2xx fast. Non-2xx is retried with backoff (up to 6 attempts).
Book into a not-yet-native calendar
interim: call.completed, capture then create
Interim, until native writes for your calendar land via the aggregator: 1. Subscribe a webhook to call.completed. 2. On each event where data.appointment_booked is true, read: data.appointment_time, customer_name, customer_phone, service_requested. 3. Create the event in your calendar (Microsoft Graph, Acuity, Calendly, Cal.com, iCloud) from those fields. Caveat: the AI did not check that calendar's live availability, so treat it as a requested slot, not a double-book-safe confirmed one.
Onboard clients via the API
POST /clients, then poll provisioning
1. Create a read_write key. 2. On your own signup form submit: POST /clients with the business details. 3. Take the job_id from the 202 response and poll GET /clients/:id/provisioning-status/:jobId every few seconds. 4. When status is "done", show the provisioned number from result and tell the client to forward their existing line to it.
Calendar booking
An appointment write (create, reschedule, cancel) reaches a real calendar based on what the client has connected. The behavior is the same whether the booking comes from the AI on a call or from the API.
Google Calendar (live)
Full calendar write. Create checks live availability, reschedule patches the event, cancel removes it. The matching event fires with the event link.
Outlook · M365 · Acuity · Calendly · other (coming)
Native writes to these are being added through a calendar aggregator. The API is already provider-agnostic, so these plug in with no change to your integration.
No calendar connected
A write creates a record-only appointment (a real row, no calendar event), so the API still succeeds. Availability returns 409 calendar_not_connected.
A record-only appointment was not checked against any live calendar, so treat it as a requested slot, not a double-book-safe confirmed one, until the client connects a calendar.
Quick reference
A compact, copyable map of the whole API, handy to paste into an AI assistant you are building an integration with.
VoiceAI Connect Developer API, v1
base_url: https://urchin-app-bqb4i.ondigitalocean.app/api/v1
auth: Authorization: Bearer <api_key> (or x-api-key: <api_key>)
plan: Scale only (call-center agencies any plan; trialing = Scale); else 403 upgrade_required
scopes: read (GET), read_write (POST/PATCH/PUT/DELETE)
rate_limit: 120 req/min per key; headers X-RateLimit-Limit/Remaining/Reset; 429 rate_limit_exceeded
pagination: ?limit=1-100 (default 25) & ?cursor=<next_cursor>; list -> { data, has_more, next_cursor }
errors: { error: { type, message } }; types: authentication_error(401), insufficient_scope(403),
upgrade_required(403), account_suspended(403), not_found(404), invalid_request(400),
rate_limit_exceeded(429), not_implemented(501), api_error(400/500)
endpoints:
GET /account
GET /clients POST /clients(rw)
GET /clients/:id PATCH /clients/:id(rw) DELETE /clients/:id(rw -> 501 not_implemented)
GET /clients/:id/provisioning-status/:jobId
GET /clients/:id/receptionist PATCH /clients/:id/receptionist(rw)
GET /clients/:id/knowledge-base PUT /clients/:id/knowledge-base(rw)
GET /calls GET /calls/:id GET /calls/:id/recording
GET /clients/:id/availability?date=YYYY-MM-DD
GET /appointments GET /appointments/:id
POST /appointments(rw) PATCH /appointments/:id(rw) POST /appointments/:id/cancel(rw)
GET /numbers GET /usage GET /analytics/calls
GET /events
POST /webhooks(rw) GET /webhooks GET /webhooks/:id
PATCH /webhooks/:id(rw) DELETE /webhooks/:id(rw)
GET /webhooks/:id/deliveries POST /webhooks/:id/ping(rw) POST /webhooks/:id/test(rw)
events: call.started, call.completed, call.transferred, call.escalated,
appointment.booked, appointment.rescheduled, appointment.canceled,
client.provisioned, client.provisioning_failed, client.payment_failed,
client.subscription_activated, client.trial_expired, client.canceled,
client.trial_started, client.payment_succeeded,
client.cancellation_scheduled, client.cancellation_reverted
client_events: every client.* event carries data.client_id and data.client (owner, plan, trial, payment, cancellation)
webhook_signature: header X-VoiceAI-Signature: t=<unix>,v1=<hex>; v1 = HMAC-SHA256(secret, t + "." + rawBody)
webhook_headers_also: X-VoiceAI-Event, X-VoiceAI-Delivery
webhook_retries: up to 6 attempts, backoff 1/5/30/120/360/720 min, 10s timeout, needs 2xx
calendar: appointment writes hit the client's connected calendar (Google live now; Outlook/Acuity/Calendly via aggregator coming). No calendar connected = record-only appointment.
docs: https://www.myvoiceaiconnect.com/developersRoadmap
Not available yet:
- Native calendar writes for Microsoft 365 / Outlook, Cal.com, Calendly, Acuity (in progress via a calendar aggregator; the appointment API already supports them without a change on your side).
- A call.followup_required event for calls that need a human (voicemail, callback requested, unanswered, complaint, abandoned).
- Client deletion via the API (today DELETE /clients returns 501; offboard from the dashboard).