API
For running your own automation against your account. If your practice already has a workflow, or you built one with an agent, this is how it reads the book and writes back without anyone sitting in the dashboard.
Four endpoints, one auth header. The base URL is https://office-voice.com. JSON in, JSON out, and every error has the same shape: {"ok": false, "error": "..."}. 401 means we do not know who you are, 403 means we do and this key may not, 429 means you may but not this fast.
Keys, scopes and orgs
Mint a key under API keys. Owners only. The secret starts with ovk_live_ and is shown once; we store a SHA-256 of it, so if you lose it, revoke and mint another.
Authorization: Bearer ovk_live_...
A key belongs to the account, not to one org. If you are a practice, one key reads your whole client book. You can narrow a key to named client orgs when you create it, and that narrowing only ever subtracts: an org that later leaves the account stops being reachable by an old key, and a key left unnarrowed picks up clients you onboard afterwards without being reissued.
Add ?tenant_id= to any read to focus one org. An id your key is not entitled to returns 404 rather than 403, so the API never confirms an org exists that you cannot see.
contacts:read— the open book.contacts:write— record a promise to pay. Does not contact anyone.campaigns:read— chase runs and their outcomes.audit:read— the compliance trail. Its own scope, because it covers every debtor on the account and a key minted to poll run outcomes should not silently carry that.
Read the open book
GET /api/v1/contacts, scope contacts:read. Returns what is still open and needs a decision, not every row we hold. Filter with ?kind=dispute,lapsed_promise,escalation.
curl -H "Authorization: Bearer ovk_live_..." \
"https://office-voice.com/api/v1/contacts?kind=lapsed_promise&limit=100"
# -> {"ok":true,"tenant_ids":["..."],"count":12,"total":12,
# "counts":{"dispute":0,"lapsed_promise":12,"escalation":0},
# "items":[{"tenant_id":"...","kind":"lapsed_promise","thread_kind":"invoice",
# "thread_ref":"INV-1042","since":"2026-08-14","age_days":28,
# "detail":"Promised 14 Aug, nothing received"}]}Every item carries tenant_id, so a practice reading the whole book can attribute each one. total counts everything matched rather than just this page, so you can tell you are paging without a second request.
Record a promise to pay
POST /api/v1/promises, scope contacts:write. For the case where the debtor told you: they rang the office, replied to an email, or said it to the bookkeeper. Without this, the chase keeps running against someone who has already committed to a date.
curl -X POST https://office-voice.com/api/v1/promises \
-H "Authorization: Bearer ovk_live_..." \
-H "Content-Type: application/json" \
-d '{"tenant_id":"...","thread_ref":"INV-1042","thread_kind":"invoice",
"amount":2400,"date":"2026-09-30","method":"bank_transfer"}'
# -> {"ok":true,"tenant_id":"...","thread_ref":"INV-1042","created":false,
# "promise_to_pay":{"amount":2400,"date":"2026-09-30",
# "method":"bank_transfer","captured_at":"..."},
# "next_followup_date":"2026-09-30"}It merges rather than replaces, so a promise sent here will not wipe a dispute or the touch count we learned on a call. It also moves the follow-up date to the promised date, which is the point: recording a promise and leaving the chase clock alone is how a debtor who committed gets rung the next morning.
A write must name one org.A read can span your whole book; “record this against forty orgs” is not a thing anyone means, so a practice key has to pass tenant_id. amountmay be null, because “they said they would pay it” with no figure is a real outcome.
Read chase runs
GET /api/v1/campaigns, scope campaigns:read. Filter with ?status=QUEUED,RUNNING,DONE,CANCELLED.
The number worth reading is skipped_count. A run that dialled two of ninety has not failed: eighty-eight targets hit a compliance gate, and the reason each one did is in the audit trail below.
Read the compliance trail
GET /api/v1/audit, scope audit:read. Read-only by construction: there is no writer on this route. This is the receipt, pullable into your own system: what we decided about which debtor and when, including the DNCR exemption claim that records why a call was lawful.
curl -H "Authorization: Bearer ovk_live_..." \
"https://office-voice.com/api/v1/audit?type=DNCR_EXEMPTION_CLAIMED&limit=100"
# -> {"ok":true,"since":"...","until":null,"count":100,
# "next_cursor":"...","rows":[...]}type— comma-separated. An unknown value returns 400 with the valid list, never an empty page: an empty page in a compliance trail reads as “nothing ever happened”, and that must never be what a typo looks like.since/until— ISO timestamps. Default window is 30 days, and the window you got is echoed back.customer_id/conversation_id— narrow to one.limit— default 50, max 200.cursor— pass backnext_cursor. It is keyset, so nothing written between requests is skipped.
Rate limits
60 requests a minute per key by default, settable per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 carries Retry-After in seconds.
What the API cannot do
Worth reading before you design around it, because these are deliberate rather than missing.
It cannot make a phone ring.There is no endpoint that starts a chase. Dialling stays with the campaign dispatcher, under calling hours in the customer’s timezone, the Do Not Call Register, do-not-contact, the daily cap and disclosure on the call. No key reaches past any of that.
It cannot send anything to a customer. No SMS, no email, no pay link. contacts:write records what you already know; it does not contact anyone on your behalf.
If something here does not match what the API actually did, tell us. A reference that has drifted from the routes is worse than none.