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.
conversations:read— Read conversations, messages and their statusesconversations:write— Send replies on the business’s own channelscontacts:read— Read contact recordsmessages:ingest— Push inbound messages into the business’s inboxevents:read— Read the event feed and manage webhook subscriptions
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.
/v1/whoami · conversations:read/v1/conversations · conversations:read/v1/conversations/{id} · conversations:read/v1/conversations/{id}/messages · conversations:read/v1/conversations/{id}/messages · conversations:write/v1/messages/inbound · messages:ingest/v1/contacts/{id} · contacts:read/v1/events · events:read/v1/webhooks · events:read/v1/webhooks · events:read/v1/webhooks/{id} · events:readSending 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.
conversation.created— A new conversation was opened — on any channel, not only yoursmessage.received— A customer said somethingmessage.sent— A reply went out, whether from a person, the AI, or your own callmessage.held_for_approval— The AI drafted a reply and a human has to release it
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'])
endRetries 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.