API reference

Build on your inbox.

Read conversations, send replies on the channels your customers already use, push messages in from your own site or app, and get told when something happens. One key, five scopes, JSON over HTTPS.

Getting a key

The account owner creates one in Settings → Advanced → API access. It is shown once and stored only as a hash — if it is lost, revoke it and make another. Keys are per business, and API access is part of the Pro plan.

curl https://officepilot.biz/api/v1/whoami \
  -H "Authorization: Bearer op_live_xxxxxxxx_…"

Four refusals mean four different things: 401 the key is wrong or revoked,403 the key lacks the scope, 402 the plan does not include API access, 429 too many requests — 60 a minute per key, with a Retry-After header.

Scopes

A key carries only what you tick when you create it. Each endpoint names what it needs.

Endpoints

No endpoint takes a business id. The key is the business, so there is nothing to pass and nothing that could be passed by mistake.

GET /v1/whoami · conversations:read
What this key is: the business it belongs to, its scopes, and whether the AI answers for it.
GET /v1/conversations · conversations:read
Newest first. `limit` up to 100, `before` is an ISO timestamp, `status` and `channel` filter.
GET /v1/conversations/{id} · conversations:read
One conversation and its contact.
GET /v1/conversations/{id}/messages · conversations:read
Oldest first — a transcript reads forwards. Tool bookkeeping rows are left out.
POST /v1/conversations/{id}/messages · conversations:write
Reply on the conversation’s own channel. The channel is not a parameter: a thread that arrived by Messenger is answered on Messenger.
POST /v1/messages/inbound · messages:ingest
A customer said something on your surface. Lands in the inbox; the AI answers under the business’s rules.
GET /v1/contacts/{id} · contacts:read
One contact.
GET /v1/events · events:read
The event log, oldest first. `after` is the last `seq` you processed.
GET /v1/webhooks · events:read
Your endpoints, with their delivery state.
POST /v1/webhooks · events:read
Subscribe an https endpoint. The signing secret is returned once.
DELETE /v1/webhooks/{id} · events:read
Unsubscribe.

Sending a reply

curl -X POST https://officepilot.biz/api/v1/conversations/CONVERSATION_ID/messages \
  -H "Authorization: Bearer $OFFICEPILOT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-own-id-42" \
  -d '{"text": "We can be there Thursday morning."}'

It goes out on that conversation’s channel under the same rules the dashboard obeys — including the customer’s own consent. A reply to somebody who opted out of texts is refused with 400 and the attempt is recorded, not silently dropped.

Taking a message in

curl -X POST https://officepilot.biz/api/v1/messages/inbound \
  -H "Authorization: Bearer $OFFICEPILOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact": {"external_id": "web-session-91", "name": "Dana"},
    "text": "Do you fix tankless heaters?"
  }'

The contact is matched against the business’s existing records, so somebody who already exists by phone and arrives by email is one person, not two. The response carries the AI’s answer — or queued_for_approval: true when the business reviews replies before they go out, in which case show your customer nothing and wait for the message.sent event.

Events

Poll them, or subscribe. Both read the same rows in the same order.

curl "https://officepilot.biz/api/v1/events?after=1042&limit=50" \
  -H "Authorization: Bearer $OFFICEPILOT_KEY"

Keep the last seq you processed and send it as after. A webhook delivery carries the same seq, so you can start with polling and move to webhooks later without wondering what you have already seen.

Webhooks

curl -X POST https://officepilot.biz/api/v1/webhooks \
  -H "Authorization: Bearer $OFFICEPILOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.example.com/officepilot",
       "event_types": ["message.received", "message.sent"]}'

The URL must be https. The response includes a signing secret, shown once. A new subscription starts from now — you will not be sent the back catalogue.

Deliveries are in order: if one fails, the next attempt starts from the same event rather than skipping it, so you never have a hole to notice on your own. Any 2xx counts as received. After 15 consecutive failures the endpoint is disabled and the reason is recorded — create a new subscription once your side is healthy.

Verifying a delivery

Every delivery carries X-OfficePilot-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" with your endpoint’s secret. Compare against the raw body, before any JSON parsing, and reject a timestamp more than five minutes old.

# Python
import hashlib, hmac, time

def verify(secret: str, raw: bytes, header: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
// Node
const crypto = require('crypto')

function verify(secret, raw, header, tolerance = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
  const ts = Number(parts.t)
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.`).update(raw).digest('hex')
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
# Ruby
require 'openssl'

def verify(secret, raw, header, tolerance = 300)
  parts = header.split(',').map { |p| p.split('=', 2) }.to_h
  return false if (Time.now.to_i - parts['t'].to_i).abs > tolerance
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{parts['t']}.#{raw}")
  OpenSSL.secure_compare(expected, parts['v1'])
end

Retries and idempotency

Send an Idempotency-Key on any POST — any string you choose. A repeat with the same key returns the first response instead of sending a second message. Without one, a retry is a second message, which is the honest reading of a request that did not identify itself.

Questions

Get in touch — tell us what you are building and which endpoint is in your way.