API referencev2.10.1

API reference

Run a sub-account from your own software or an AI assistant: leads, deal value and won jobs, pipeline stages, bookings and calendars, texts and emails, the inbox, calls, forms, automations and settings. 173 endpoints, one MCP server.

API address
https://api.ottopros.com
MCP server
https://api.ottopros.com/mcp
Machine-readable

Overview

Every path below starts with /v1 after the API address, and every answer has the same shape: { "success": true, "data": { ... }, "error": null }, or success: false with an error message and a matching HTTP status.

A key works inside its own sub-account only. Leave out workspace_id wherever you see it: the key fills it in, a different one is ignored, and records of any other sub-account answer 404.

Your first call
curl "https://api.ottopros.com/v1/leads?limit=5" \
  -H "Authorization: Bearer $API_KEY"

Authentication

An admin of the sub-account creates keys in the CRM under Settings → API. Each key is shown once; revoke it there the moment you stop using it. Send it with every request:

Header
Authorization: Bearer zu-...
Access levelWhat the key can do
Read onlyLook things up. Nothing is created, changed or sent.
Read and writeCreate and update leads, bookings and settings, send messages and enroll leads in automations. Cannot delete things or manage the team.
Full accessEverything, including deleting, team members, and sending lead data to other tools (webhooks, alert recipients).

Each endpoint below shows the level it needs. Calls also follow the role of the person who made the key: a key keeps working only while they are an admin of the sub-account.

Quick start

Pick your language once; every example on the page follows it.

Mark a job won and record what it was worth

Set the lead's status and deal value (in cents). Moving it to a pipeline stage works the same with stage_key.

cURL
curl -X PATCH "https://api.ottopros.com/v1/leads/LEAD_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "status": "won",
    "deal_value_cents": 250000
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    status: "won",
    deal_value_cents: 250000,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/leads/LEAD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "status": "won",
        "deal_value_cents": 250000,
    },
)
print(res.json())

Book an appointment

Pick a calendar (GET /v1/calendars), find an open time (GET /v1/calendars/{calendar_id}/availability), then book it. The customer gets the usual confirmation and reminders unless you send notify_lead: false.

cURL
curl -X POST "https://api.ottopros.com/v1/bookings" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_id": "LEAD_ID",
    "calendar_id": "CALENDAR_ID",
    "starts_at": "2026-11-04T17:00:00Z",
    "duration_minutes": 60
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_id: "LEAD_ID",
    calendar_id: "CALENDAR_ID",
    starts_at: "2026-11-04T17:00:00Z",
    duration_minutes: 60,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/bookings",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_id": "LEAD_ID",
        "calendar_id": "CALENDAR_ID",
        "starts_at": "2026-11-04T17:00:00Z",
        "duration_minutes": 60,
    },
)
print(res.json())

Add a lead

Matched to an existing contact by phone or email, so sending the same person twice does not make a duplicate.

cURL
curl -X POST "https://api.ottopros.com/v1/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "full_name": "Jane Smith",
    "phone": "+15125550123",
    "email": "jane@example.com",
    "inquiry": "Roof leak over the garage"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    full_name: "Jane Smith",
    phone: "+15125550123",
    email: "jane@example.com",
    inquiry: "Roof leak over the garage",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "full_name": "Jane Smith",
        "phone": "+15125550123",
        "email": "jane@example.com",
        "inquiry": "Roof leak over the garage",
    },
)
print(res.json())

Text a customer

Sent from the sub-account's number, exactly like a text from the inbox. Add send_at to schedule it.

cURL
curl -X POST "https://api.ottopros.com/v1/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_id": "LEAD_ID",
    "channel": "sms",
    "body": "Hi Jane, we can come out Tuesday at 10."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_id: "LEAD_ID",
    channel: "sms",
    body: "Hi Jane, we can come out Tuesday at 10.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_id": "LEAD_ID",
        "channel": "sms",
        "body": "Hi Jane, we can come out Tuesday at 10.",
    },
)
print(res.json())

AI assistants (MCP)

Claude, ChatGPT, Cursor and other assistants can run the sub-account through the MCP server at https://api.ottopros.com/mcp (Streamable HTTP, JSON-RPC 2.0). Use the same API key; the assistant can do exactly what the key allows, so a Read only key is a safe way to start.

Claude (web, desktop, mobile): custom connector

Settings → Connectors → Add custom connector. Name it, paste this URL, leave OAuth blank. Treat the URL like a password.

URL
https://api.ottopros.com/mcp?api_key=zu-YOUR_API_KEY

Claude Code

bash
claude mcp add crm --transport http https://api.ottopros.com/mcp \
  --header "Authorization: Bearer zu-YOUR_API_KEY"

Cursor, Claude Desktop config, Windsurf, VS Code

Paste into the app's MCP settings (for example ~/.cursor/mcp.json), then restart it.

json
{
  "mcpServers": {
    "crm": {
      "url": "https://api.ottopros.com/mcp",
      "headers": {
        "Authorization": "Bearer zu-YOUR_API_KEY"
      }
    }
  }
}

ChatGPT / OpenAI Responses API

json
{
  "tools": [
    {
      "type": "mcp",
      "server_label": "crm",
      "server_url": "https://api.ottopros.com/mcp",
      "headers": {
        "Authorization": "Bearer zu-YOUR_API_KEY"
      },
      "require_approval": "never"
    }
  ]
}

Tools

Account
get_api_key_info — Access level, sub-account lock, rate limit, credit budget and 30-day usage of the connected key.
Sub-account setup
get_setup_checklist — Start here: what is configured, what is missing, and the tool that fixes each finding.
manage_offers — Offers: first text, follow-ups, AI notes, triggers. list / create / update / delete.
manage_calendars — Booking calendars, their hosts and their booking page: prices with options and add-ons, deposit, design, full days, shared schedule, booking questions. create / update / copy_design / generate_design.
manage_booking_menus — Service menus: one booking link listing several calendars in sections. list / create / update / delete / share_schedule / generate_design.
manage_calendar_routes — Calendar routing: which calendar each lead books on, from their answers or tags. list / create / update / delete / reorder / match.
business_hours — Time zone, weekly hours, days off, slot length. get / set.
manage_pipeline_stages — Add, rename, hide, reorder or delete pipeline stages.
manage_custom_fields — Custom lead fields. list / create / update / archive.
lead_routing — Round-robin assignment of new leads, in even turns or by percentage. get / set.
manage_alert_recipients — Who is texted or emailed about new leads and AI receptionist calls (admin scope to change).
manage_lead_sources — Private URLs that accept leads from any form or tool.
manage_outbound_webhooks — Push lead, appointment, message and AI receptionist call events to Zapier, Make or a CRM (admin scope).
knowledge_base — The FAQ the AI answers from. get / add / replace.
ai_receptionist — The AI that answers the phone. get / configure.
manage_smart_forms — Create a smart form, change its basics, questions (photos, prices on answers), instant estimate, service area and calendar routing, publish it.
configure_workspace_outreach — Auto-greet, AI replies, follow-ups, reminders, sending identity.
ai_texting — The AI texting agent and automatic texts: replies on/off, name, reply mode, first text, send window, reminders, missed-call text, prompt rules. get / set.
nurture_sequence — The automatic follow-up texts and emails. get / set / edit / enable / disable / clear.
business_profile — Name, industry, time zone, address, country, business phone and email. get / set.
list_phone_numbers — The sub-account's numbers and whether texting is ready.
Dashboard & inbox
get_overview — Dashboard numbers: leads, booked, speed to lead, AI replies, calls, and how many leads reached each pipeline stage.
get_usage — Credits used by tool and by day.
list_conversations — The Inbox: conversations with unread counts, last message and AI state.
update_conversation — Mark read / unread, archive / unarchive.
pause_lead_ai — Pause or resume the AI on one lead.
list_reply_suggestions — AI suggested replies waiting for approval.
decide_reply_suggestion — Approve (sends, charged like an AI reply) or dismiss a suggested reply.
list_calls — Phone calls with outcome and AI summary.
get_call — One call: summary, transcript, 15-minute recording link.
call_campaigns — AI calling campaigns: list with progress, or pause one.
busy_blocks — Team members' busy time from other software: list, set (add or replace by your id), remove, sync a window.
ai_lessons — The AI Brain: list lessons, add rules, switch off, reword or delete.
rate_ai_message — Thumbs up / down on an AI message to teach the AI.
invoices — Invoices, read only (deposit and balance invoices say so). list / get.
estimates — Estimates: list / get / the price book, and create (a draft with Good / Better / Best options), update, send to the lead, convert to an invoice, delete.
get_ad_insights — The sub-account's Ad Insights: spend, leads and cost per lead across its Meta ad accounts, per campaign and ad.
google_ads — Its Google Ads: the connection and conversions sent back, and live reports (campaigns, keywords, search terms...).
google_business — Google Business Profiles: profiles, reviews and replies, posts, Profile Optimizer runs.
map_rankings — Map Rankings: keywords, scans, one scan's grid and next steps.
business_listings — Business Listings: each directory's status and what's needed.
support_tickets — Support tickets and their messages.
ai_brain_photos — AI Brain photos the AI may send: list, add from a link, change, remove.
ai_brain_waiting — Questions waiting on you: list, answer (trains the AI, resumes it), dismiss.
get_sales_report — The Sales page: won jobs, parts, tips and profit by technician, lead source and tag.
list_activity — The Activity log: every change and who made it, access, messages, calls, automations and errors.
reviews — Review pages, who rated what, and a lead's review link.
CRM leads
list_recent_leads — Leads, newest first. Filter by tags (any or all), status, stage, assignee (a user id, me or unassigned), a name/email/phone search and since.
list_lead_tags — A sub-account's lead tags and how many leads carry each.
get_lead — One lead with its inquiry, address, custom fields, tags and timeline.
create_lead — Create a lead with inquiry, address, custom fields, stage and tags (de-duplicated by phone and email).
update_lead — Update any lead field, custom fields and stage; add, remove or replace tags.
delete_lead — Delete a lead, and its appointments too if asked (admin scope).
get_lead_timeline — Everything that happened with a lead.
add_lead_note — Add a note to a lead, tagging teammates (@mentions) if you like.
set_lead_stage — Move a lead to a pipeline stage.
manage_lead_note — Edit a note you wrote, or delete a note.
mark_do_not_contact — Stop all texts and emails to a lead (no undo).
list_pipeline_stages — The sub-account's pipeline stages.
Messaging
send_lead_message — Send a text or email to a lead, now or at a later time (send_at).
scheduled_messages — Scheduled texts and emails: list, update, cancel, restore, send_now.
get_lead_conversation — The text and email thread with a lead.
list_messages — Message history across the sub-account.
get_message — One message with its delivery status.
get_message_limits — Today's sending limits and what is left.
manage_imessage — iMessage through the sub-account's own Macs: list_lines (health, Advanced mode, usage), update_line, send (a text or voice message as iMessage from one of them), react (a tapback on an iMessage).
Bookings
list_calendars — Booking calendars with their pricing, design and booking form.
get_calendar_availability — Open appointment slots, for the option and add-ons picked.
list_bookings — Appointments.
create_booking — Book an appointment, with the option and add-ons picked.
reschedule_booking — Move an appointment.
cancel_booking — Cancel an appointment.
update_booking — Record an appointment's outcome, change its title, notes, location, host or lead notifications, or link a hand-typed appointment to its lead.
Sub-accounts & team
list_workspaces — Your sub-accounts.
get_workspace — One sub-account.
update_workspace — Rename, re-time-zone or archive a sub-account; turn optional features on (Settings > Features) or hide features from its people.
list_workspace_members — Members and pending invites.
invite_workspace_member — Invite someone to a sub-account (admin scope).
update_workspace_member_role — Change a member's role (admin scope).
remove_workspace_member — Remove a member (admin scope).
Forms & automations
list_smart_forms — Smart forms with views and submissions.
list_form_submissions — Submissions of a form.
list_workspace_automations — A sub-account's automations.
enroll_leads_in_automation — Enroll leads in an automation (can send real messages). Leads already queued for it are skipped; ignore_filters defaults to false, so leads failing its only_if rules are skipped.
toggle_workspace_automation — Turn a sub-account automation on or off.
manage_workspace_automation — Build automations: reference / get / create / update / duplicate / delete (changes need admin scope). Triggers can name Smart Forms, calendars and Facebook forms; email steps can pick a sender; inbound webhooks return their URL.
workflow_enrollments — Who is in an automation, a lead's automations, and taking leads out.

Errors

StatusMeaningWhat to do
400Invalid request body or unknown action.Fix the payload; retrying unchanged returns the same error.
401Missing, malformed or revoked API key.Re-copy the key from Settings → API.
402The account is out of credits for this message or call, or the key's monthly credit budget is used up.Add credits to the account, or raise the key's budget in Settings → API.
403The key lacks the scope, or it is restricted to one sub-account and this endpoint is account-level.Create a key with that scope in Settings → API. The response names the scope it needs.
404Unknown route or record.Check the path and the id.
409Conflict, e.g. a lead with that phone or email already exists, or the lead is already booked at that time. Also idempotency_in_progress: the first request with this Idempotency-Key is still running.Use the id returned in details. For idempotency_in_progress, send the request again with the same key a moment later.
429Per-key rate limit exceeded.Wait for Retry-After seconds; read X-RateLimit-Remaining.
503Pricing or a limit check is temporarily unavailable. Nothing was charged.Retry with exponential backoff.
5xxTransient server or upstream failure.Retry with exponential backoff.

Limits and conventions

Authentication

Send your key as Authorization: Bearer zu-.... Admins of a sub-account create keys in the CRM under Settings → API. A key works inside its own sub-account only: you can leave out workspace_id everywhere, a different one is ignored, and records of any other sub-account answer 404. Access levels: Read only, Read and write, or Full access (deleting things, team members and anything that sends lead data outside the account). Every call also follows the role of the person who made the key: a key keeps working only while they are an admin of the sub-account.

Idempotency

Every write (POST, PUT, PATCH, DELETE) accepts Idempotency-Key: <your-unique-string>. Replaying the same key with the same body within 24 hours returns the original response instead of doing the work twice (no second message, no second booking); the same key with a different body answers 409. Use a fresh key per logical operation, not per retry.

Rate limits

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. On 429 a Retry-After header (and retry_after_seconds in the body) tells you how long to wait. The limit is per key, per minute: 120 by default, adjustable from 1 to 600 in Settings → API.

Pagination

List endpoints accept limit (default 50, max 200) and return the newest rows first. GET /v1/messages is the one endpoint with a cursor: pass cursor (or since) and follow next_cursor to walk forward without duplicates.

Messaging safety limits

To protect number reputation, each sub-account can message at most 300 unique contacts per UTC day (plus per-minute burst and daily segment caps). Replying to a contact you already messaged that day does not consume another contact slot. Messaging endpoints return X-Contacts-Limit, X-Contacts-Used, X-Contacts-Remaining and X-Contacts-Reset; over the cap sends answer 429 with code: "sms_limit" and a limit object. Check the current state any time with GET /v1/messages/limits. Need a higher ceiling? Request a limit increase from Settings → Messaging and we'll review it.

Responses and errors

Success is { success: true, data, error: null }. Errors are { success: false, data: null, error: string, details?: object } with a matching HTTP status; scope, rate-limit and budget errors put a machine-readable code in error (scope_denied, rate_limit_exceeded, credit_limit_exceeded) and the explanation in message. Branch on the status and the code, not on message text.

Webhooks

Get events pushed to your own URL: set a webhook up in Settings → Leads → Outbound webhooks, or with POST /v1/outbound-webhooks below.

EventWhen it fires
lead.createdA lead was captured for this sub-account. Payload is a full lead snapshot including custom form fields.
lead.updatedThe lead's status or pipeline stage changed (other field edits do not fire it). Payload carries previous_status, status, previous_stage_key and stage_key.
lead.qualifiedThe lead was marked qualified.
appointment.bookedAn appointment was booked for the lead.
appointment.rescheduledAn appointment moved to a new time, in the CRM calendar, through the API or by an inbound webhook. Payload carries booking_id, lead_id, the new starts_at / ends_at, timezone, host, calendar, meeting and moved_via (app, api or inbound_webhook).
appointment.cancelledA booked appointment was cancelled.
message.receivedAn inbound SMS or email arrived on this sub-account's number or inbox. Payload carries message_id, lead_id, channel, direction, body, from/to and media_urls, and transport (imessage or mac_sms) when it came in on one of the sub-account's iMessage Macs.
message.sentAn outbound SMS or email was handed to the carrier or mail provider — manual, automated or API-sent. Texts carry transport: sms from the business number, imessage or mac_sms when one of the sub-account's Macs sent it.
message.failedAn outbound message could not be sent. Payload includes the error and, when the contact-safety cap was hit, a limit object.
call.completedAn AI receptionist call ended (once per call, after its recap is written; practice calls from Settings excluded). Payload carries call_id, lead_id (null for a caller with no lead), from/to, status, duration_sec, outcome, summary, booking_id and started_at / ended_at. The transcript and recording: GET /v1/calls/{call_id}.
deal.updatedA lead was marked won, or a won lead's amount or plan changed later (an amount added from Ad Insights, a plan edited). Payload carries deal_type (one_time or plan), deal_value_cents (the first payment), deal_plan_cents, deal_plan_interval, deal_monthly_cents and deal_expected_total_cents, plus the lead snapshot. Every lead event's snapshot carries the same deal fields.

Verify each delivery: X-Webhook-Signature: sha256=<hex hmac>. Each delivery is signed with HMAC-SHA256 over the exact raw request body using the webhook's secret (shown once when it is created). Compute the HMAC over the raw bytes, before JSON parsing, and compare in constant time. X-Webhook-Event carries the event name and X-Webhook-Delivery a unique delivery id you can use to de-duplicate retries.

Setup

#

What is set up, and what is missing

GET/v1/setup-status
Read only

Start here. Returns the sub-account's real configuration (numbers, AI texting, receptionist, calendars, forms, automations, offers, team, lead sources, integrations) and a severity-ranked list of findings. Each finding names the API call and MCP tool that fixes it, or says when it can only be done in the app (buying a number, SMS registration, connecting Google or Outlook).

Request
cURL
curl "https://api.ottopros.com/v1/setup-status?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/setup-status?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/setup-status?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Read the business profile

GET/v1/business-profile
Read only

Name, industry, time zone, address, country, business phone and business email (Settings → General).

Request
cURL
curl "https://api.ottopros.com/v1/business-profile?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/business-profile?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/business-profile?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Change the business profile

PATCH/v1/business-profile
Read and writeIdempotent

Send only what changes. A new time zone also moves the booking hours' time zone, as in the app.

ParameterDescription
name
body · string
Max 120.
industry
body · string
Max 80.
timezone
body · string
IANA zone, e.g. America/Chicago.
address_line1
body · string
Street.
address_line2
body · string
Suite, unit.
city
body · string
City.
state
body · string
State or region.
postal_code
body · string
ZIP or postal code.
country
body · string
Two-letter country code (US, CA, GB, AU; "United States" works too), or null. It picks the State list on leads, forms and booking pages, and the list a lead's state is matched against when the lead has no phone.
business_phone
body · string
US 10-digit or + and a country code.
business_email
body · string
Email address.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/business-profile" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "timezone": "America/Chicago",
    "country": "US",
    "business_phone": "+14155550123"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/business-profile", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    timezone: "America/Chicago",
    country: "US",
    business_phone: "+14155550123",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/business-profile",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "timezone": "America/Chicago",
        "country": "US",
        "business_phone": "+14155550123",
    },
)
print(res.json())
#

The sub-account's phone numbers

GET/v1/phone-numbers
Read only

Number, name, default, capabilities, texting registration and caller-name status, and who may use it: assigned_user_id (a number that belongs to one team member: it rings only them and other technicians can't text from it), tech_can_answer and tech_can_use (whether it rings technicians and whether they may call and text from it). Buying and releasing numbers is done in the app. iMessage Macs: GET /v1/imessage/lines.

ParameterDescription
include_released
query · boolean
Include released numbers.
Request
cURL
curl "https://api.ottopros.com/v1/phone-numbers?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/phone-numbers?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/phone-numbers?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "phone_numbers": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "phone_number": "+14155550199",
        "friendly_name": "Main line",
        "is_default": true,
        "capabilities": {
          "sms": true,
          "voice": true,
          "mms": true
        },
        "sms_registration_status": "ready",
        "cnam_display_name": "ACME ROOFING",
        "cnam_status": "approved",
        "shaken_status": null,
        "voice_forward_enabled": false,
        "created_at": "2026-09-01T00:00:00Z",
        "released_at": null,
        "texting_ready": true
      }
    ]
  },
  "error": null
}

Offers

#

List offers

GET/v1/offers
Read only

Offers (offer playbooks) tell the AI what this business sells to a lead: the first text, the follow-up sequence, notes for the AI and the booking link.

Request
cURL
curl "https://api.ottopros.com/v1/offers?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/offers?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/offers?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Create an offer

POST/v1/offers
Read and writeIdempotent
ParameterDescription
name
body · string · required
Internal name (max 80).
offer_label
body · string
How the offer is described to the lead (max 200).
first_message_template
body · string
First text to a new lead. Supports {{first_name}} (max 1600).
nurture_enabled
body · boolean
null follows the sub-account's own follow-up setting.
nurture_steps
body · array
Up to 12 follow-ups: { offset_minutes, body_template }. offset_minutes counts from the previous message.
ai_context_note
body · string
What the AI should know about this offer (max 2000).
booking_link
body · string
Public http(s) URL.
is_active
body · boolean
Default true.
smart_first_text_enabled
body · boolean
true: the AI writes the first text for these leads (first_message_template is the backup); false: first_message_template goes out as written; null (default) follows the sub-account's smart first text switch.
smart_first_text_instructions
body · string
What the smart first text should do for these leads (max 1000). Empty uses the sub-account's instructions.
triggers
body · array
Which leads get this offer: { type, value, label? }. type is meta_form, smart_form, webhook (a lead source id) or source (the lead's source text). Form and lead-source ids must belong to the sub-account.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/offers" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Free roof inspection",
    "offer_label": "a free 20-point roof inspection",
    "first_message_template": "Hi {{first_name}}, thanks for asking about a free roof inspection. What day works best?",
    "nurture_steps": [
      {
        "offset_minutes": 60,
        "body_template": "Still want that inspection?"
      }
    ],
    "triggers": [
      {
        "type": "source",
        "value": "Website form"
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/offers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Free roof inspection",
    offer_label: "a free 20-point roof inspection",
    first_message_template: "Hi {{first_name}}, thanks for asking about a free roof inspection. What day works best?",
    nurture_steps: [
      {
        offset_minutes: 60,
        body_template: "Still want that inspection?",
      },
    ],
    triggers: [
      {
        type: "source",
        value: "Website form",
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/offers",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Free roof inspection",
        "offer_label": "a free 20-point roof inspection",
        "first_message_template": "Hi {{first_name}}, thanks for asking about a free roof inspection. What day works best?",
        "nurture_steps": [
            {
                "offset_minutes": 60,
                "body_template": "Still want that inspection?",
            },
        ],
        "triggers": [
            {
                "type": "source",
                "value": "Website form",
            },
        ],
    },
)
print(res.json())
#

Update an offer

PATCH/v1/offers/{offer_id}
Read and writeIdempotent

Send only what changes. triggers, when sent, replaces the whole list.

ParameterDescription
offer_id
path · string · required
Offer id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/offers/OFFER_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/offers/OFFER_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/offers/OFFER_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Delete an offer

DELETE/v1/offers/{offer_id}
Full accessIdempotent
ParameterDescription
offer_id
path · string · required
Offer id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/offers/OFFER_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/offers/OFFER_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/offers/OFFER_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

Calendars & hours

#

Create a booking calendar

POST/v1/calendars
Read and writeIdempotent

A new calendar has no host unless host_user_id or hosts is sent. Hosts must be members of the sub-account (GET /v1/members). The response is the calendar as GET /v1/calendars shows it, with its public booking path (/c/<slug>). Calendars are switched off with is_active=false rather than deleted. The booking page can show prices with options and add-ons and take a deposit (pricing), have its own look (design), book whole working days (duration_days), share one schedule with other calendars (schedule_group) and ask its own questions (questions). Taking money at booking needs Stripe, Authorize.net or Whop connected to the sub-account in the app first.

ParameterDescription
name
body · string · required
Calendar name (max 80).
title
body · string
Shown to the person booking.
duration_minutes
body · number
5-1440, default 30.
buffer_minutes
body · number
0-1440, default 0. Always saved as 0 on an arrival_window calendar (a window books the whole window and pads nothing), as in the app.
min_notice_minutes
body · number
Default 60.
max_days_ahead
body · number
1-365, default 14.
timezone
body · string
IANA zone, e.g. America/Chicago. Defaults to the sub-account's.
weekly_hours
body · object
Per day, 24h HH:MM windows: {"mon":[["09:00","17:00"]],"sat":[]}. Default Mon-Fri 9-5.
date_overrides
body · array
Days off or special hours: [{"date":"2026-12-25","pairs":[]}].
assignment_mode
body · string
Who takes the booking. Default unassigned (no host), or single when host_user_id is sent.
One of: single, round_robin, collective, unassigned
host_user_id
body · string
Host of a single-host calendar (required for single).
hosts
body · array
User ids sharing a round_robin / collective calendar.
meeting_type
body · string
Default phone. in_person: the customer comes to meeting_location (studio, shop, office). customer_address: the team goes to the customer's home, and the booking page asks for their address.
One of: zoom, google_meet, phone, phone_attendee, in_person, customer_address, custom
scheduling_mode
body · string
exact times, or an arrival window for trades.
One of: exact, arrival_window
arrival_window_minutes
body · number
15-720.
appointment_texts
body · object
Confirmation and reminder texts for appointments on this calendar, keyed by confirmation, reminder_24h, reminder_1h, reminder_5m. Each takes {"mode":"custom","template":"..."} (this wording, sent even when the sub-account has that text off), {"mode":"off"} (never sent for this calendar), or null / {"mode":"inherit"} (the sub-account's text, the default). Only the texts you name change.
slots_per_window
body · number
1-50: how many jobs can run at the same moment (crews).
duration_days
body · number
Full-day jobs, 1-14 working days: each booking runs from opening time on its first day to closing time on its last, skipping closed days. null = timed by duration_minutes.
schedule_group
body · string
Shared schedule name (max 60): calendars with the same group count each other's bookings, so one crew's time is never sold twice across services. null = its own schedule.
pricing
body · object
Prices on the booking page. Merged into what the calendar has, except options and addons, which replace their lists (send each row's id to keep it; ids come back in GET /v1/calendars). Money in cents. { enabled, show_prices, price_cents (no options), price_note (max 160), options_label ("Vehicle size"), options: [{ id?, name (max 80), description?, price_cents, duration_minutes (5-1440, its own job length), duration_days (full-day calendars), image_url? }] (max 24; the customer picks one), addons_label, addons: [{ id?, name, description?, price_cents, extra_minutes (0-720), image_url? }] (max 24), deposit: { mode: none | fixed | percent | full, amount_cents, percent (1-100) } }. A calendar that never had pricing switches it on unless enabled is false; null clears it. A deposit needs a connected gateway.
design
body · object
The booking page look, merged into the current design. { enabled, preset (clean, showroom, midnight, fresh, warm, bold, ocean, spa, mono, graphite: applied first), mode: light | dark | auto, colors: { background, surface, text, button } (#rrggbb, merged), font, heading_font ("match" or a font), radius: sharp | soft | rounded | pill, button_style: solid | soft | outline, backdrop: solid | glow | photo, logo_url, cover_url (https), hide_avatar, custom_css (max 12000) }. Fonts: inter, poppins, dm-sans, space-grotesk, manrope, outfit, figtree, plus-jakarta, work-sans, rubik, nunito-sans, montserrat, raleway, urbanist, sora, lexend, archivo, barlow, karla, public-sans, bricolage, bebas, anton, archivo-black, syne, clash, oswald, playfair, dm-serif, lora, libre-baskerville, cormorant, instrument-serif, fraunces, source-serif, merriweather, jetbrains-mono, space-mono, ibm-plex-mono, system. A page that never had a design switches it on unless enabled is false; null = the classic page. It carries into embeds and custom domains.
questions
body · array
The calendar's own booking questions, up to 30, replacing the list: [{ key?, label (max 160), field_type: text | textarea | number | money | phone | email | url | select | multiselect | boolean | date | datetime | rating | file (photos or PDFs, up to 6), options (select / multiselect: 2-40), option_style?, required?, enabled?, save_to_crm? (never for file), placeholder?, helper_text? }]. Answers show on the appointment and the lead.
fields
body · object
Contact fields on the booking form, merged: {"address":{"enabled":true,"required":true,"autocomplete":true},"email":{"required":false}}. Keys: full_name, first_name, last_name, email, phone, address, state, notes. Phone or email stays on; full_name or first_name + last_name.
image_url
body · string
The service's photo (https): on its booking page and its service menu card.
currency
body · string
Prices and payments in this currency.
One of: usd, cad, gbp, eur, aud
payment_gateway
body · string
Charge through this connected gateway; null = whichever is connected (Stripe first).
One of: stripe, authorize_net, whop
payment_hold_minutes
body · number
15-120: how long the time is held while they pay.
payment_label
body · string
What the checkout calls the payment, e.g. "Deposit" (max 60).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/calendars" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Triple T Interior + Exterior",
    "duration_minutes": 240,
    "assignment_mode": "unassigned",
    "schedule_group": "Main crew",
    "max_days_ahead": 120,
    "pricing": {
      "options_label": "Vehicle size",
      "options": [
        {
          "name": "Car",
          "price_cents": 31500,
          "duration_minutes": 240
        },
        {
          "name": "SUV",
          "price_cents": 38500,
          "duration_minutes": 300
        },
        {
          "name": "Van",
          "price_cents": 53500,
          "duration_minutes": 360
        }
      ],
      "addons": [
        {
          "name": "Pet hair",
          "price_cents": 4000,
          "extra_minutes": 30
        }
      ],
      "deposit": {
        "mode": "fixed",
        "amount_cents": 5000
      }
    },
    "design": {
      "preset": "showroom"
    },
    "questions": [
      {
        "label": "Vehicle year / make / model",
        "field_type": "text",
        "required": true
      },
      {
        "label": "Photos of the vehicle",
        "field_type": "file"
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Triple T Interior + Exterior",
    duration_minutes: 240,
    assignment_mode: "unassigned",
    schedule_group: "Main crew",
    max_days_ahead: 120,
    pricing: {
      options_label: "Vehicle size",
      options: [
        {
          name: "Car",
          price_cents: 31500,
          duration_minutes: 240,
        },
        {
          name: "SUV",
          price_cents: 38500,
          duration_minutes: 300,
        },
        {
          name: "Van",
          price_cents: 53500,
          duration_minutes: 360,
        },
      ],
      addons: [
        {
          name: "Pet hair",
          price_cents: 4000,
          extra_minutes: 30,
        },
      ],
      deposit: {
        mode: "fixed",
        amount_cents: 5000,
      },
    },
    design: {
      preset: "showroom",
    },
    questions: [
      {
        label: "Vehicle year / make / model",
        field_type: "text",
        required: true,
      },
      {
        label: "Photos of the vehicle",
        field_type: "file",
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/calendars",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Triple T Interior + Exterior",
        "duration_minutes": 240,
        "assignment_mode": "unassigned",
        "schedule_group": "Main crew",
        "max_days_ahead": 120,
        "pricing": {
            "options_label": "Vehicle size",
            "options": [
                {
                    "name": "Car",
                    "price_cents": 31500,
                    "duration_minutes": 240,
                },
                {
                    "name": "SUV",
                    "price_cents": 38500,
                    "duration_minutes": 300,
                },
                {
                    "name": "Van",
                    "price_cents": 53500,
                    "duration_minutes": 360,
                },
            ],
            "addons": [
                {
                    "name": "Pet hair",
                    "price_cents": 4000,
                    "extra_minutes": 30,
                },
            ],
            "deposit": {
                "mode": "fixed",
                "amount_cents": 5000,
            },
        },
        "design": {
            "preset": "showroom",
        },
        "questions": [
            {
                "label": "Vehicle year / make / model",
                "field_type": "text",
                "required": True,
            },
            {
                "label": "Photos of the vehicle",
                "field_type": "file",
            },
        ],
    },
)
print(res.json())
#

Update a booking calendar

PATCH/v1/calendars/{calendar_id}
Read and writeIdempotent

Takes the same fields as create; send only what changes. hosts, when sent, replaces the pool; people who stay keep their place in the rotation. An arrival_window calendar always keeps buffer_minutes at 0 (a full-day one keeps its buffer). appointment_texts changes only the texts it names. pricing, design and fields merge; pricing.options, pricing.addons and questions replace their lists. Turning pricing off also stops taking payment at booking.

ParameterDescription
calendar_id
path · string · required
Calendar id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/calendars/CALENDAR_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "pricing": {
      "deposit": {
        "mode": "percent",
        "percent": 20
      }
    },
    "design": {
      "colors": {
        "button": "#d4a63a"
      }
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars/CALENDAR_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    pricing: {
      deposit: {
        mode: "percent",
        percent: 20,
      },
    },
    design: {
      colors: {
        button: "#d4a63a",
      },
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/calendars/CALENDAR_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "pricing": {
            "deposit": {
                "mode": "percent",
                "percent": 20,
            },
        },
        "design": {
            "colors": {
                "button": "#d4a63a",
            },
        },
    },
)
print(res.json())
#

Copy a calendar's design to others

POST/v1/calendars/{calendar_id}/copy-design
Read and writeIdempotent

The Design tab's "Use on other calendars": this calendar's design goes onto the calendars named (or every other calendar of the sub-account with all: true), switched on. A calendar that has its own cover photo keeps it. The calendar must have a design switched on.

ParameterDescription
calendar_id
path · string · required
The calendar whose design is copied.
calendar_ids
body · array
The calendars to copy it to.
all
body · boolean
Every other calendar of the sub-account instead.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/calendars/CALENDAR_ID/copy-design" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "all": true
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars/CALENDAR_ID/copy-design", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    all: true,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/calendars/CALENDAR_ID/copy-design",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "all": True,
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "from_calendar_id": "uuid",
    "copied_to": [
      {
        "id": "uuid",
        "name": "Quick wash"
      }
    ],
    "design": {
      "enabled": true,
      "preset": "showroom",
      "mode": "dark"
    }
  },
  "error": null
}
#

Design a booking page with AI

POST/v1/calendars/{calendar_id}/design/generate
Read and writeIdempotent

The Design tab's AI designer: describe the look in one line and/or give the business's website (its colours, fonts and logo are read from it). Returns the design and a one-line summary, and saves it on the calendar unless save is false. readable says whether the text reads well on the card. No credits used.

ParameterDescription
calendar_id
path · string · required
Calendar id.
instruction
body · string
What it should look like, e.g. "black and gold, like a luxury showroom" (max 1200).
website_url
body · string
The business's website.
save
body · boolean
Default true. false only returns the design (send it later with PATCH design).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/calendars/CALENDAR_ID/design/generate" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "instruction": "black and gold, like a luxury showroom",
    "website_url": "https://example.com"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars/CALENDAR_ID/design/generate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    instruction: "black and gold, like a luxury showroom",
    website_url: "https://example.com",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/calendars/CALENDAR_ID/design/generate",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "instruction": "black and gold, like a luxury showroom",
        "website_url": "https://example.com",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "design": {
      "enabled": true,
      "preset": null,
      "mode": "dark",
      "colors": {
        "background": "#09090b",
        "surface": "#141416",
        "text": "#f5f5f4",
        "button": "#d4a63a"
      },
      "font": "montserrat",
      "heading_font": "oswald",
      "radius": "soft",
      "button_style": "solid",
      "backdrop": "glow",
      "logo_url": null,
      "cover_url": null,
      "hide_avatar": false,
      "custom_css": null
    },
    "summary": "Dark showroom look with gold buttons and condensed headings.",
    "saved": true,
    "website": {
      "url": "https://example.com",
      "read": true
    },
    "readable": true
  },
  "error": null
}
#

List service menus

GET/v1/booking-menus
Read only

Service menus (Calendars → Booking tools → Service menu): one booking link (/c/<slug>, public_path) that lists several calendars in sections, with search, category chips and sorting; the customer picks a service and books it there. Each section lists its services with their price and length as the page shows them. warnings says when its services are on separate schedules (two customers could book the same time on different services) or switched off.

Request
cURL
curl "https://api.ottopros.com/v1/booking-menus?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/booking-menus?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "booking_menus": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "slug": "to-the-t-services",
        "name": "All services",
        "title": "Book a detail",
        "description": null,
        "sections": [
          {
            "id": "sec1",
            "title": "Quick Services",
            "description": null,
            "calendar_ids": [
              "uuid"
            ],
            "services": [
              {
                "calendar_id": "uuid",
                "name": "Express wash",
                "title": "Express wash",
                "is_active": true,
                "price_from_cents": 6000,
                "price_to_cents": 9000,
                "length_label": "1-2 hr",
                "schedule_group": "Main crew"
              }
            ]
          }
        ],
        "options": {
          "show_prices": true,
          "show_durations": true,
          "search": true,
          "filters": true,
          "sorting": true,
          "default_sort": "featured"
        },
        "design": null,
        "is_active": true,
        "public_path": "/c/to-the-t-services",
        "warnings": [],
        "created_at": "2026-10-23T12:00:00Z",
        "updated_at": "2026-10-23T12:00:00Z"
      }
    ]
  },
  "error": null
}
#

Create a service menu

POST/v1/booking-menus
Read and writeIdempotent

Without sections it starts like the app's: one "Services" section with every calendar that is switched on, and a link named after the business. The link shares /c/ with calendars, so a slug a calendar or another menu uses answers 409.

ParameterDescription
title
body · string
Heading customers see (max 120). Default "Book a service".
name
body · string
Internal name (max 80). Default "All services".
description
body · string
Under the heading (max 600).
slug
body · string
Link name: /c/<slug> (letters, numbers, dashes; max 40).
sections
body · array
In order, replacing the list: [{ id?, title (max 80), description?, calendar_ids: [calendar ids, in the order shown] }]. Up to 30 sections; every calendar must belong to the sub-account.
options
body · object
Merged: { show_prices, show_durations, search, filters (category chips), sorting, default_sort: featured | price_low | price_high | shortest | name }. All on by default.
design
body · object
The page look, the same shape as a calendar's design. It carries into each service's booking flow opened from the menu.
is_active
body · boolean
Default true.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/booking-menus" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "title": "Book a detail",
    "sections": [
      {
        "title": "Quick Services",
        "calendar_ids": [
          "uuid",
          "uuid"
        ]
      },
      {
        "title": "Detailing Packages",
        "calendar_ids": [
          "uuid"
        ]
      }
    ],
    "design": {
      "preset": "showroom"
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    title: "Book a detail",
    sections: [
      {
        title: "Quick Services",
        calendar_ids: ["uuid", "uuid"],
      },
      {
        title: "Detailing Packages",
        calendar_ids: ["uuid"],
      },
    ],
    design: {
      preset: "showroom",
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/booking-menus",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "title": "Book a detail",
        "sections": [
            {
                "title": "Quick Services",
                "calendar_ids": ["uuid", "uuid"],
            },
            {
                "title": "Detailing Packages",
                "calendar_ids": ["uuid"],
            },
        ],
        "design": {
            "preset": "showroom",
        },
    },
)
print(res.json())
#

Update a service menu

PATCH/v1/booking-menus/{menu_id}
Read and writeIdempotent

Takes the same fields as create; send only what changes. sections replaces the list; options and design merge.

ParameterDescription
menu_id
path · string · required
Menu id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/booking-menus/MENU_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "options": {
      "default_sort": "price_low"
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus/MENU_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    options: {
      default_sort: "price_low",
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/booking-menus/MENU_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "options": {
            "default_sort": "price_low",
        },
    },
)
print(res.json())
#

Delete a service menu

DELETE/v1/booking-menus/{menu_id}
Read and writeIdempotent

Its link stops working; the calendars on it are not touched.

ParameterDescription
menu_id
path · string · required
Menu id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/booking-menus/MENU_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus/MENU_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/booking-menus/MENU_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Put a menu's services on one schedule

POST/v1/booking-menus/{menu_id}/share-schedule
Read and writeIdempotent

The menu editor's "Share one schedule": every calendar on the menu gets the same schedule_group, so a booking on any of them takes that time on all of them (one crew doing every service). group names it; by default the group one of them already has, else the menu's title.

ParameterDescription
menu_id
path · string · required
Menu id.
group
body · string
The shared schedule's name (max 60).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/booking-menus/MENU_ID/share-schedule" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus/MENU_ID/share-schedule", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/booking-menus/MENU_ID/share-schedule",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "schedule_group": "Main crew",
    "calendar_ids": [
      "uuid",
      "uuid"
    ],
    "changed": [
      "uuid"
    ]
  },
  "error": null
}
#

Design a service menu with AI

POST/v1/booking-menus/{menu_id}/design/generate
Read and writeIdempotent

Same as POST /v1/calendars/{calendar_id}/design/generate, for a service menu's page.

ParameterDescription
menu_id
path · string · required
Menu id.
instruction
body · string
What it should look like.
website_url
body · string
The business's website.
save
body · boolean
Default true.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/booking-menus/MENU_ID/design/generate" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/booking-menus/MENU_ID/design/generate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/booking-menus/MENU_ID/design/generate",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Get business hours and booking defaults

GET/v1/availability
Read only
Request
cURL
curl "https://api.ottopros.com/v1/availability?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/availability?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/availability?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Set business hours and booking defaults

PUT/v1/availability
Read and writeIdempotent

The AI uses these to decide when it may text and which times to offer. Setting the time zone also updates the sub-account's time zone.

ParameterDescription
timezone
body · string
IANA zone.
weekly_hours
body · object
Same shape as a calendar's.
date_overrides
body · array
Days off or special hours.
slot_minutes
body · number
5-240.
buffer_minutes
body · number
0-120. Always saved as 0 when scheduling_mode is arrival_window.
default_calendar_id
body · string
A calendar of this sub-account, or null.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/availability" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "timezone": "America/Chicago",
    "weekly_hours": {
      "mon": [
        [
          "08:00",
          "17:00"
        ]
      ],
      "sat": [
        [
          "09:00",
          "13:00"
        ]
      ]
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/availability", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    timezone: "America/Chicago",
    weekly_hours: {
      mon: [
        ["08:00", "17:00"],
      ],
      sat: [
        ["09:00", "13:00"],
      ],
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/availability",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "timezone": "America/Chicago",
        "weekly_hours": {
            "mon": [
                ["08:00", "17:00"],
            ],
            "sat": [
                ["09:00", "13:00"],
            ],
        },
    },
)
print(res.json())
#

List calendar routing rules

GET/v1/calendar-routes
Read only

Calendar routing (Calendars -> Calendar routing in the app) sends each lead to the calendar that fits their answers: "Player group is Girls 13-14 -> Girls 13-14 calendar". Rules are checked in order (position 1 first); the first rule whose conditions ALL match the lead's custom fields or tags picks the calendar. Smart Forms with calendar_routing on, the AI texter and the AI caller follow them. Each rule comes with its calendar's name and a readable summary. Rules that are switched off, or whose calendar is off, are skipped.

Request
cURL
curl "https://api.ottopros.com/v1/calendar-routes?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calendar-routes?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "calendar_routes": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "calendar_id": "uuid",
        "calendar_name": "Girls 13-14",
        "calendar_active": true,
        "name": "Girls 13-14",
        "conditions": [
          {
            "kind": "field",
            "key": "player_group",
            "op": "is",
            "values": [
              "Girls 13-14"
            ],
            "min": null,
            "max": null
          }
        ],
        "summary": "Player group is Girls 13-14",
        "position": 1,
        "is_active": true,
        "created_at": "2026-10-07T18:00:00Z",
        "updated_at": "2026-10-07T18:00:00Z"
      }
    ]
  },
  "error": null
}
#

Add a calendar routing rule

POST/v1/calendar-routes
Read and writeIdempotent

Added last unless you send position. Conditions: a field condition reads a custom field by key (GET /v1/custom-fields) with op is, is_not or contains and values (any one matches), or op between with min and/or max for numbers ("11 years old" counts as 11); a tag condition (kind tag, op is or is_not) reads the lead's tags. Answers compare ignoring case, accents and punctuation. The calendar must belong to the sub-account. warnings explains a rule that may never fire (a key that is not a custom field, a calendar that is switched off). Owners and admins only; at most 200 rules.

ParameterDescription
calendar_id
body · string · required
The calendar this rule books on (GET /v1/calendars).
conditions
body · array · required
All must match, 1-20: [{"kind":"field","key":"player_group","op":"is","values":["Girls 13-14"]}], [{"kind":"field","key":"age","op":"between","min":9,"max":12}], [{"kind":"tag","op":"is","values":["VIP"]}].
name
body · string
Optional label, max 120.
is_active
body · boolean
Default true.
position
body · number
1 = checked first. Default last.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/calendar-routes" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "calendar_id": "uuid",
    "name": "Girls 13-14",
    "conditions": [
      {
        "kind": "field",
        "key": "player_group",
        "op": "is",
        "values": [
          "Girls 13-14"
        ]
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    calendar_id: "uuid",
    name: "Girls 13-14",
    conditions: [
      {
        kind: "field",
        key: "player_group",
        op: "is",
        values: ["Girls 13-14"],
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/calendar-routes",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "calendar_id": "uuid",
        "name": "Girls 13-14",
        "conditions": [
            {
                "kind": "field",
                "key": "player_group",
                "op": "is",
                "values": ["Girls 13-14"],
            },
        ],
    },
)
print(res.json())
Response 201
JSON
{
  "success": true,
  "data": {
    "calendar_route": {
      "id": "uuid",
      "workspace_id": "uuid",
      "calendar_id": "uuid",
      "calendar_name": "Girls 13-14",
      "calendar_active": true,
      "name": "Girls 13-14",
      "conditions": [
        {
          "kind": "field",
          "key": "player_group",
          "op": "is",
          "values": [
            "Girls 13-14"
          ],
          "min": null,
          "max": null
        }
      ],
      "summary": "Player group is Girls 13-14",
      "position": 3,
      "is_active": true,
      "created_at": "2026-10-16T18:00:00Z",
      "updated_at": "2026-10-16T18:00:00Z"
    }
  },
  "error": null
}
#

Change a calendar routing rule

PATCH/v1/calendar-routes/{route_id}
Read and writeIdempotent

Send any of calendar_id, name, conditions (replaces the list) and is_active. To move a rule use POST /v1/calendar-routes/reorder. Owners and admins only.

ParameterDescription
route_id
path · string · required
Rule id.
calendar_id
body · string
Another calendar of the sub-account.
conditions
body · array
Replaces the conditions (see create).
name
body · string
Max 120.
is_active
body · boolean
false keeps the rule but skips it.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/calendar-routes/ROUTE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "is_active": false
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes/ROUTE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    is_active: false,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/calendar-routes/ROUTE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "is_active": False,
    },
)
print(res.json())
#

Delete a calendar routing rule

DELETE/v1/calendar-routes/{route_id}
Read and writeIdempotent

Owners and admins only. The other rules keep their order.

ParameterDescription
route_id
path · string · required
Rule id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/calendar-routes/ROUTE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes/ROUTE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/calendar-routes/ROUTE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Set the order rules are checked in

POST/v1/calendar-routes/reorder
Read and writeIdempotent

The first rule that matches wins, so put narrower rules first. order lists every rule id of the sub-account exactly once, first to last. Answers the rules in their new order. Owners and admins only.

ParameterDescription
order
body · array · required
Every rule id, first to last.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/calendar-routes/reorder" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "order": [
      "uuid-rule-b",
      "uuid-rule-a"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes/reorder", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    order: ["uuid-rule-b", "uuid-rule-a"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/calendar-routes/reorder",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "order": ["uuid-rule-b", "uuid-rule-a"],
    },
)
print(res.json())
#

Which calendar a lead routes to

GET/v1/calendar-routes/match
Read only

Runs the rules the way the booking paths do (active rules on calendars that are on) against a lead's custom fields and tags, and/or answers you pass. status: matched (calendar and the route that won), pending (no rule matched yet but one still can: missing_fields are the answers to ask for before offering times), unmatched (no rule fits: the form's or the AI's own calendar applies), or no_rules. Viewers limited to their own leads only reach those leads.

ParameterDescription
lead_id
query · string
The lead to route. Send lead_id, answers or both.
answers
query · string
JSON object of field key to answer to try, laid over the lead's own, e.g. {"player_group":"Girls 13-14"}.
Request
cURL
curl "https://api.ottopros.com/v1/calendar-routes/match" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendar-routes/match", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calendar-routes/match",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead_id": "uuid",
    "status": "pending",
    "calendar": null,
    "route": null,
    "missing_fields": [
      {
        "key": "player_group",
        "label": "Player group"
      }
    ],
    "rules_checked": 4
  },
  "error": null
}

Team busy times

#

Add or update a team member's busy block

PUT/v1/busy-blocks/{external_id}
Read and writeIdempotent

For a schedule kept in other software (a field-service app, a dispatch board): the person is treated as booked for that time, so the AI (text, chat and phone), Smart Forms, booking pages and round robin only offer times when they are free. external_id is YOUR id for the block (a job or visit id, URL-encoded in the path); sending the same id again replaces the block, so it is safe to resend. A block can make several people busy (a two-tech job). Team members are named by user id or email (GET /v1/members); technicians must be invited as members first. Times need a time zone (Z or an offset). At most 31 days per block. 201 when created, 200 when replaced (changed: false when nothing differed). Owners and admins of the sub-account only.

ParameterDescription
external_id
path · string · required
Your own id for the block: 1-200 letters, digits or . _ : ~ @ # | + = -
team_member
body · string
The busy person: a team member's user id or email. Or team_members for several.
team_members
body · array
Several busy people (user ids or emails), up to 50.
starts_at
body · string · required
ISO 8601 with a zone, e.g. 2026-10-14T08:00:00-05:00.
ends_at
body · string · required
ISO 8601 with a zone; after starts_at.
title
body · string
Optional note shown to the team in the CRM calendar (never to leads or the AI), max 200.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "team_member": "tech.alex@example.com",
    "starts_at": "2026-10-14T08:00:00-05:00",
    "ends_at": "2026-10-14T11:30:00-05:00",
    "title": "Job #4821 water heater install"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    team_member: "tech.alex@example.com",
    starts_at: "2026-10-14T08:00:00-05:00",
    ends_at: "2026-10-14T11:30:00-05:00",
    title: "Job #4821 water heater install",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "team_member": "tech.alex@example.com",
        "starts_at": "2026-10-14T08:00:00-05:00",
        "ends_at": "2026-10-14T11:30:00-05:00",
        "title": "Job #4821 water heater install",
    },
)
print(res.json())
Response 201
JSON
{
  "success": true,
  "data": {
    "busy_block": {
      "id": "uuid",
      "external_id": "job-4821",
      "team_members": [
        {
          "id": "uuid",
          "email": "tech.alex@example.com",
          "name": "Alex Rivera"
        }
      ],
      "starts_at": "2026-10-14T13:00:00.000Z",
      "ends_at": "2026-10-14T16:30:00.000Z",
      "title": "Job #4821 water heater install",
      "source": "api",
      "created_at": "2026-10-09T15:02:11Z",
      "updated_at": "2026-10-09T15:02:11Z"
    },
    "created": true,
    "changed": true
  },
  "error": null
}
#

Remove a busy block

DELETE/v1/busy-blocks/{external_id}
Read and writeIdempotent

By your own id; the time opens up again at once. 404 when there is no block with that id.

ParameterDescription
external_id
path · string · required
Your id for the block (URL-encoded).
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID?workspace_id=WORKSPACE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/busy-blocks/EXTERNAL_ID?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

List busy blocks

GET/v1/busy-blocks
Read only

Blocks that overlap the window, earliest first. Default window: from yesterday, 60 days long; at most 366 days.

ParameterDescription
team_member
query · string
Only this person's blocks (user id or email).
from
query · string
ISO start with a zone.
to
query · string
ISO end with a zone.
limit
query · number
1-500, default 100.
offset
query · number
Skip this many (has_more says whether there are more).
Request
cURL
curl "https://api.ottopros.com/v1/busy-blocks?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/busy-blocks?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/busy-blocks?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Sync a time window in one call

POST/v1/busy-blocks/sync
Read and writeIdempotent

Send your full list of busy blocks for a window: blocks in the list are added or replaced, and blocks you sent earlier that START inside [from, to) but are no longer in the list are removed (cancelled or moved jobs). With team_member, only that person's blocks are replaced and each block defaults to them; a removed block that also names other people keeps them. Up to 500 blocks per call. Good for a nightly or hourly full sync next to the per-block calls. Owners and admins only.

ParameterDescription
from
body · string · required
Window start, ISO 8601 with a zone.
to
body · string · required
Window end; at most 366 days after from.
team_member
body · string
Sync one person only (user id or email).
blocks
body · array · required
[{ external_id, team_member | team_members, starts_at, ends_at, title? }]. [] clears the window.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/busy-blocks/sync" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": "2026-10-13T00:00:00-05:00",
    "to": "2026-10-20T00:00:00-05:00",
    "team_member": "tech.alex@example.com",
    "blocks": [
      {
        "external_id": "job-4821",
        "starts_at": "2026-10-14T08:00:00-05:00",
        "ends_at": "2026-10-14T11:30:00-05:00"
      },
      {
        "external_id": "pto-2210",
        "starts_at": "2026-10-17T00:00:00-05:00",
        "ends_at": "2026-10-18T00:00:00-05:00",
        "title": "Day off"
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/busy-blocks/sync", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    from: "2026-10-13T00:00:00-05:00",
    to: "2026-10-20T00:00:00-05:00",
    team_member: "tech.alex@example.com",
    blocks: [
      {
        external_id: "job-4821",
        starts_at: "2026-10-14T08:00:00-05:00",
        ends_at: "2026-10-14T11:30:00-05:00",
      },
      {
        external_id: "pto-2210",
        starts_at: "2026-10-17T00:00:00-05:00",
        ends_at: "2026-10-18T00:00:00-05:00",
        title: "Day off",
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/busy-blocks/sync",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "from": "2026-10-13T00:00:00-05:00",
        "to": "2026-10-20T00:00:00-05:00",
        "team_member": "tech.alex@example.com",
        "blocks": [
            {
                "external_id": "job-4821",
                "starts_at": "2026-10-14T08:00:00-05:00",
                "ends_at": "2026-10-14T11:30:00-05:00",
            },
            {
                "external_id": "pto-2210",
                "starts_at": "2026-10-17T00:00:00-05:00",
                "ends_at": "2026-10-18T00:00:00-05:00",
                "title": "Day off",
            },
        ],
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "created": 1,
    "updated": 0,
    "unchanged": 1,
    "removed": 2,
    "window": {
      "from": "2026-10-13T05:00:00.000Z",
      "to": "2026-10-20T05:00:00.000Z"
    },
    "team_member": {
      "id": "uuid",
      "email": "tech.alex@example.com",
      "name": "Alex Rivera"
    }
  },
  "error": null
}

Pipeline & fields

#

Add a pipeline stage

POST/v1/stages
Read and writeIdempotent

behavior is what the stage means for reporting. The key is made from the label and never changes.

ParameterDescription
label
body · string · required
Stage name (max 40).
behavior
body · string
Default contacted.
One of: new, contacted, booked, won, lost
color
body · string
Defaults to the first unused colour.
One of: blue, amber, violet, cyan, emerald, rose, teal, orange, pink, indigo, lime, slate
manual_only
body · boolean
Moving a lead here does not change its status.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/stages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "label": "Quote sent",
    "behavior": "contacted"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/stages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    label: "Quote sent",
    behavior: "contacted",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/stages",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "label": "Quote sent",
        "behavior": "contacted",
    },
)
print(res.json())
#

Rename, recolour or hide a stage

PATCH/v1/stages/{stage_id}
Read and writeIdempotent

The five built-in stages (new, contacted, booked, won, lost) can only change label, color and hidden.

ParameterDescription
stage_id
path · string · required
Stage id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/stages/STAGE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/stages/STAGE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/stages/STAGE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Delete a custom stage

DELETE/v1/stages/{stage_id}
Full accessIdempotent

Its leads move to the stage named in move_to. Built-in stages cannot be deleted; hide them instead.

ParameterDescription
stage_id
path · string · required
Stage id.
move_to
query · string · required
Key of the stage that takes over the leads.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/stages/STAGE_ID?move_to=MOVE_TO" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/stages/STAGE_ID?move_to=MOVE_TO", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/stages/STAGE_ID?move_to=MOVE_TO",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Reorder the pipeline

POST/v1/stages/reorder
Read and writeIdempotent
ParameterDescription
order
body · array · required
Every stage id of the sub-account, first to last.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/stages/reorder" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/stages/reorder", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/stages/reorder",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

List custom lead fields

GET/v1/custom-fields
Read only
ParameterDescription
include_archived
query · boolean
Include archived fields.
Request
cURL
curl "https://api.ottopros.com/v1/custom-fields?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/custom-fields?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/custom-fields?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Add a custom lead field

POST/v1/custom-fields
Read and writeIdempotent

field_key and field_type cannot change later, because stored lead values depend on them.

ParameterDescription
label
body · string · required
Field label (max 80).
field_type
body · string
Default text.
One of: text, textarea, number, money, phone, email, select, date, datetime, url, boolean, rating, multiselect
field_key
body · string
Defaults to a key made from the label.
options
body · array
Choices for select / multiselect.
required
body · boolean
Default false.
show_in_list
body · boolean
Show as a column in the leads list.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/custom-fields" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "label": "Roof type",
    "field_type": "select",
    "options": [
      "Shingle",
      "Metal",
      "Tile"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/custom-fields", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    label: "Roof type",
    field_type: "select",
    options: ["Shingle", "Metal", "Tile"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/custom-fields",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "label": "Roof type",
        "field_type": "select",
        "options": ["Shingle", "Metal", "Tile"],
    },
)
print(res.json())
#

Update a custom field

PATCH/v1/custom-fields/{field_id}
Read and writeIdempotent

Send archived: false to restore an archived field.

ParameterDescription
field_id
path · string · required
Field id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/custom-fields/FIELD_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/custom-fields/FIELD_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/custom-fields/FIELD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Archive a custom field

DELETE/v1/custom-fields/{field_id}
Read and writeIdempotent

Hides the field. Values already stored on leads are kept.

ParameterDescription
field_id
path · string · required
Field id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/custom-fields/FIELD_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/custom-fields/FIELD_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/custom-fields/FIELD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

Routing & alerts

#

Get lead round-robin settings

GET/v1/lead-routing
Read only
Request
cURL
curl "https://api.ottopros.com/v1/lead-routing?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-routing?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/lead-routing?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Set lead round-robin settings

PUT/v1/lead-routing
Read and writeIdempotent

Assigns NEW LEADS to team members in turn. (A round-robin calendar is separate: it rotates appointment hosts.) An empty eligible_member_ids list means everyone takes turns. member_weights splits leads by percentage (80/20 gives one person 8 of every 10 leads, spread out); {} goes back to even turns.

ParameterDescription
round_robin_enabled
body · boolean
Turn the rotation on or off.
eligible_member_ids
body · array
User ids from GET /v1/members. Empty = everyone.
member_weights
body · object
Percentage split by user id (GET /v1/members): {"<user_id>": 80, "<other_user_id>": 20}. Whole percents 0-100; the shares above 0 must add up to exactly 100. Sets eligible_member_ids to the people with a share and restarts the rotation. {} = even turns (keeps who is in the rotation).
notify_assignee_only
body · boolean
Alert only the person the lead went to.
skip_imported_leads
body · boolean
Leave imported leads unassigned.
notify_on_reply
body · boolean
Alert the assignee when the lead replies.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/lead-routing" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "round_robin_enabled": true,
    "member_weights": {
      "user-uuid-a": 80,
      "user-uuid-b": 20
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-routing", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    round_robin_enabled: true,
    member_weights: {
      "user-uuid-a": 80,
      "user-uuid-b": 20,
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/lead-routing",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "round_robin_enabled": True,
        "member_weights": {
            "user-uuid-a": 80,
            "user-uuid-b": 20,
        },
    },
)
print(res.json())
#

List who is alerted about new leads and AI receptionist calls

GET/v1/alert-recipients
Read only
Request
cURL
curl "https://api.ottopros.com/v1/alert-recipients?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/alert-recipients?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/alert-recipients?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Add a lead alert recipient

POST/v1/alert-recipients
Full accessIdempotent

Lead details are sent to this phone or inbox, so this needs an admin-scoped key.

ParameterDescription
channel
body · string · required
sms or email.
One of: sms, email
destination
body · string · required
Phone (US 10-digit or +country code; stored as E.164) or email address.
label
body · string
e.g. Owner's cell.
notify_leads
body · boolean
Gets new-lead, appointment and reply alerts. Default true.
notify_calls
body · boolean
Gets a summary after every AI receptionist call (who called, the outcome, any message or booking, the transcript). Default false.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/alert-recipients" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "sms",
    "destination": "+14155550123",
    "label": "Owner"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/alert-recipients", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    channel: "sms",
    destination: "+14155550123",
    label: "Owner",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/alert-recipients",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "channel": "sms",
        "destination": "+14155550123",
        "label": "Owner",
    },
)
print(res.json())
#

Update a lead alert recipient

PATCH/v1/alert-recipients/{recipient_id}
Full accessIdempotent

Send any of label, destination, active, notify_leads, notify_calls. A recipient keeps at least one of notify_leads or notify_calls.

ParameterDescription
recipient_id
path · string · required
Recipient id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Remove a lead alert recipient

DELETE/v1/alert-recipients/{recipient_id}
Full accessIdempotent
ParameterDescription
recipient_id
path · string · required
Recipient id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/alert-recipients/RECIPIENT_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

Lead sources & webhooks

#

List lead source links

GET/v1/lead-sources
Read and write

Each link is a private URL that accepts leads, so reading them takes write scope. Treat the URL as a secret.

Request
cURL
curl "https://api.ottopros.com/v1/lead-sources?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-sources?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/lead-sources?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Create a lead source link

POST/v1/lead-sources
Read and writeIdempotent

Returns a URL any website form, landing page tool or CRM can POST leads (or appointments) to.

ParameterDescription
name
body · string · required
e.g. Website contact form.
field_mapping
body · object
Their field name to ours: {"Full Name":"full_name","Roof size":"custom:roof_size"}. Targets: full_name, email, phone, address, city, state, zip, inquiry, notes, appointment_* or custom:<field_key>. A state is saved as its code ("Texas" becomes TX).
default_source
body · string
Source label stamped on these leads.
default_status
body · string
Default new.
payload_kind
body · string
Default auto. update = a form your own team fills in (e.g. during a call): its answers go onto the matching contact (lead_id, phone, then email; added quietly when nobody matches) and nothing is sent: no first text, team alert, Instant Lead Connect call or new-lead automation.
One of: auto, lead, appointment, update
Request
cURL
curl -X POST "https://api.ottopros.com/v1/lead-sources" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Website contact form",
    "default_source": "Website",
    "field_mapping": {
      "Your name": "full_name",
      "Phone number": "phone"
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-sources", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Website contact form",
    default_source: "Website",
    field_mapping: {
      "Your name": "full_name",
      "Phone number": "phone",
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/lead-sources",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Website contact form",
        "default_source": "Website",
        "field_mapping": {
            "Your name": "full_name",
            "Phone number": "phone",
        },
    },
)
print(res.json())
#

Update a lead source link

PATCH/v1/lead-sources/{lead_source_id}
Read and writeIdempotent

Links created by an integration (for example Typeform) are managed there and answer 409.

ParameterDescription
lead_source_id
path · string · required
Lead source id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Delete a lead source link

DELETE/v1/lead-sources/{lead_source_id}
Full accessIdempotent
ParameterDescription
lead_source_id
path · string · required
Lead source id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/lead-sources/LEAD_SOURCE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

List outbound webhooks

GET/v1/outbound-webhooks
Read only

Never returns signing secrets or auth tokens.

Request
cURL
curl "https://api.ottopros.com/v1/outbound-webhooks?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/outbound-webhooks?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/outbound-webhooks?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Create an outbound webhook

POST/v1/outbound-webhooks
Full accessIdempotent

Pushes lead and appointment events to another system (Zapier, Make, a CRM). Lead data leaves the account, so every write needs an admin-scoped key and target_url must be https on a public host. The signing secret is returned once.

ParameterDescription
name
body · string · required
e.g. Zapier: new leads.
target_url
body · string · required
https:// URL that receives the POST.
events
body · array
Default lead.created and appointment.booked. See the sub-account webhook events list.
enabled
body · boolean
Default true.
preset
body · string
Payload flavour.
One of: zapier, make, salesforce_flow, service_titan, custom
auth_type
body · string
Default none. Send auth_token (and auth_header_name or auth_username) with it. Write-only.
One of: none, bearer, header, basic
Request
cURL
curl -X POST "https://api.ottopros.com/v1/outbound-webhooks" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Zapier: new leads",
    "target_url": "https://hooks.zapier.com/hooks/catch/123/abc/",
    "events": [
      "lead.created"
    ],
    "preset": "zapier"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/outbound-webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Zapier: new leads",
    target_url: "https://hooks.zapier.com/hooks/catch/123/abc/",
    events: ["lead.created"],
    preset: "zapier",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/outbound-webhooks",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Zapier: new leads",
        "target_url": "https://hooks.zapier.com/hooks/catch/123/abc/",
        "events": ["lead.created"],
        "preset": "zapier",
    },
)
print(res.json())
#

Update an outbound webhook

PATCH/v1/outbound-webhooks/{webhook_id}
Full accessIdempotent
ParameterDescription
webhook_id
path · string · required
Webhook id.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Delete an outbound webhook

DELETE/v1/outbound-webhooks/{webhook_id}
Full accessIdempotent
ParameterDescription
webhook_id
path · string · required
Webhook id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/outbound-webhooks/WEBHOOK_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

AI agent

#

Read the AI's FAQ

GET/v1/knowledge-base
Read only
Request
cURL
curl "https://api.ottopros.com/v1/knowledge-base?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/knowledge-base?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/knowledge-base?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Replace the AI's FAQ

PUT/v1/knowledge-base
Read and writeIdempotent

The FAQ is what the AI texting agent and AI receptionist answer from. Max 200 entries (question 300 characters, answer 2000).

ParameterDescription
entries
body · array · required
[{ question, answer }]. Replaces the whole list.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/knowledge-base" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/knowledge-base", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/knowledge-base",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Add FAQ entries

POST/v1/knowledge-base
Read and writeIdempotent

Merges into the existing list. The same question again replaces its answer.

ParameterDescription
entries
body · array · required
[{ question, answer }].
Request
cURL
curl -X POST "https://api.ottopros.com/v1/knowledge-base" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "entries": [
      {
        "question": "Do you offer financing?",
        "answer": "Yes, 0% for 12 months on approved credit."
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/knowledge-base", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    entries: [
      {
        question: "Do you offer financing?",
        answer: "Yes, 0% for 12 months on approved credit.",
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/knowledge-base",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "entries": [
            {
                "question": "Do you offer financing?",
                "answer": "Yes, 0% for 12 months on approved credit.",
            },
        ],
    },
)
print(res.json())
#

AI Brain photos

GET/v1/ai-brain/photos
Read only

Photos of the business's work the AI may text a customer who asks to see some (up to 100): link, caption, the service it shows, on / off, how often it was sent. Owners and admins.

ParameterDescription
active
query · boolean
Only on (true) or off (false).
Request
cURL
curl "https://api.ottopros.com/v1/ai-brain/photos?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/photos?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ai-brain/photos?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Add an AI Brain photo

POST/v1/ai-brain/photos
Read and writeIdempotent

From a public link to a JPEG, PNG or WebP (10 MB at most), stored like an upload on the page. The AI writes the caption and service you leave out, which is how it picks the right photo. 409 at 100 photos.

ParameterDescription
image_url
body · string · required
Public link to the image.
caption
body · string
What it shows (max 300).
service
body · string
The service (max 80), e.g. Roof repair.
active
body · boolean
Default true.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/ai-brain/photos" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "image_url": "https://example.com/jobs/roof-after.jpg",
    "service": "Roof repair"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/photos", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    image_url: "https://example.com/jobs/roof-after.jpg",
    service: "Roof repair",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/ai-brain/photos",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "image_url": "https://example.com/jobs/roof-after.jpg",
        "service": "Roof repair",
    },
)
print(res.json())
#

Change an AI Brain photo

PATCH/v1/ai-brain/photos/{photo_id}
Read and writeIdempotent
ParameterDescription
photo_id
path · string · required
Photo id.
caption
body · string
What it shows (max 300).
service
body · string
The service it shows (max 80).
active
body · boolean
Off: the AI stops sending it.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Remove an AI Brain photo

DELETE/v1/ai-brain/photos/{photo_id}
Read and writeIdempotent
ParameterDescription
photo_id
path · string · required
Photo id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/ai-brain/photos/PHOTO_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Questions waiting on you

GET/v1/ai-brain/waiting
Read only

AI Brain > Waiting on you: questions customers asked (texts, chat, DMs, calls) that the AI couldn't answer and handed to the team, newest first, one row per question (times = how many customers asked, variants = how they wrote it). Answered and dismissed ones are left out. Owners and admins.

Request
cURL
curl "https://api.ottopros.com/v1/ai-brain/waiting?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/waiting?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ai-brain/waiting?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "waiting": [
      {
        "event_id": "uuid",
        "event_ids": [
          "uuid",
          "uuid"
        ],
        "variants": [
          "Do you work on Sundays?",
          "Are you open Sunday?"
        ],
        "lead_id": "uuid",
        "lead_name": "Jane Doe",
        "question": "Do you work on Sundays?",
        "asked_at": "2026-10-24T18:02:00Z",
        "times": 2,
        "channel": "sms"
      }
    ]
  },
  "error": null
}
#

Answer a waiting question

POST/v1/ai-brain/waiting/answer
Read and writeIdempotent

As Answer it on the page: the answer becomes a saved answer (replacing one to the same question) that the AI texter and receptionist use from now on, the question leaves the list, and the AI turns back on for the leads it was paused on for that question. Nothing is sent to the customer.

ParameterDescription
question
body · string · required
The question as listed.
answer
body · string · required
What the AI should say (max 2000).
event_id
body · string
The row's event_id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/ai-brain/waiting/answer" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "event_id": "uuid",
    "question": "Do you work on Sundays?",
    "answer": "No, we'\''re open Monday to Saturday, 8 to 6."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/waiting/answer", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    event_id: "uuid",
    question: "Do you work on Sundays?",
    answer: "No, we're open Monday to Saturday, 8 to 6.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/ai-brain/waiting/answer",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "event_id": "uuid",
        "question": "Do you work on Sundays?",
        "answer": "No, we're open Monday to Saturday, 8 to 6.",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "saved": true,
    "ok": true,
    "replaced": false,
    "entries": 18,
    "resumed_lead_ids": [
      "uuid"
    ],
    "still_waiting": 0
  },
  "error": null
}
#

Dismiss waiting questions

POST/v1/ai-brain/waiting/dismiss
Read and writeIdempotent

Takes questions off the list without teaching the AI (it still treats them as open); undo: true puts them back.

ParameterDescription
event_ids
body · array
The rows' event_ids.
questions
body · array
Or the questions themselves.
undo
body · boolean
Put them back.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/ai-brain/waiting/dismiss" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-brain/waiting/dismiss", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/ai-brain/waiting/dismiss",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Read the AI receptionist settings

GET/v1/ai-receptionist
Read only

Also returns the list of voices, and collect_fields as a list of keys.

Request
cURL
curl "https://api.ottopros.com/v1/ai-receptionist?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-receptionist?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ai-receptionist?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Configure the AI receptionist

PUT/v1/ai-receptionist
Read and writeIdempotent

The AI that answers the sub-account's phone. Answered calls bill 1.5 credits per minute of AI conversation (15-second steps; time after a transfer to a person bills as phone minutes), and it needs a phone number on the sub-account to do anything (the response carries a warning when there is none).

ParameterDescription
enabled
body · boolean
Turn it on or off.
mode
body · string
Every call, or only outside business hours.
One of: always, after_hours
goal
body · string
What it tries to do.
One of: book, support, book_and_support
agent_name
body · string
Max 40.
voice
body · string
One of the voices returned by GET.
greeting
body · string
Max 400.
after_hours_greeting
body · string
Max 400.
extra_instructions
body · string
Max 1200.
transfer_enabled
body · boolean
Allow transfers to a person.
transfer_numbers
body · array
Up to 8 phone numbers.
take_messages
body · boolean
Take a message when nobody is available.
max_call_seconds
body · number
60-1800.
record_calls
body · boolean
Record answered calls (billed per recorded minute).
update_contact_details
body · boolean
Let it save the caller's name, email and address to the lead.
collect_fields
body · array
Details it asks for before booking: "address", "email" or custom field keys (GET /v1/custom-fields). Max 12.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/ai-receptionist" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "enabled": true,
    "mode": "after_hours",
    "goal": "book_and_support",
    "agent_name": "Ava",
    "collect_fields": [
      "address",
      "roof_type"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-receptionist", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    enabled: true,
    mode: "after_hours",
    goal: "book_and_support",
    agent_name: "Ava",
    collect_fields: ["address", "roof_type"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/ai-receptionist",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "enabled": True,
        "mode": "after_hours",
        "goal": "book_and_support",
        "agent_name": "Ava",
        "collect_fields": ["address", "roof_type"],
    },
)
print(res.json())
#

Read the AI texting agent and automatic text settings

GET/v1/ai-texting
Read only

AI replies, agent name, reply mode, goal, what it knows, booking link, first text, send window, hand-off, confirmation and reminder texts, missed-call text-back, email greeting, and a preview of its prompt. Says whether the sub-account has a phone number.

Request
cURL
curl "https://api.ottopros.com/v1/ai-texting?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-texting?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ai-texting?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Change the AI texting agent and automatic texts

PATCH/v1/ai-texting
Read and writeIdempotent

Send only the settings to change, by these names: ai_replies, agent_name, reply_mode (auto | suggest), goal (book_appointment | collect_details | customer_support), business_context, booking_link, max_replies (1-15), reply_delay_minutes (1-120), ai_can_cancel, handoff_when_unsure, pause_ai_on_handoff, handoff_resume_days (0, 1, 2, 3, 7), greet_new_leads, first_message, smart_first_text (the AI writes each new lead's first text from their answers and the right calendar's open times; first_message becomes the backup), smart_first_text_goal (what that text should get them to do; empty = book an appointment), greet_delay_minutes, form_greet_delay_minutes, missed_call_text, missed_call_message, confirmation_text, confirmation_message, confirmation_email (the confirmation email with a calendar invite, plus the move and cancel emails), confirmation_email_language (en | es), reminder_24h, reminder_24h_message, reminder_1h, reminder_1h_message, reminder_5m (the 5-minute reminder, off by default), reminder_5m_message, email_greeting, email_greeting_subject, email_greeting_body, send_window (anytime | daily | booking_hours), send_window_start, send_window_end ("HH:MM"), ai_answers_email, prompt_addition (one more rule) or prompt (a full rewrite). Owners, agency admins and client admins can change these. Turning AI replies on lets it answer conversations still waiting for a reply.

ParameterDescription
ai_replies
body · boolean
The AI answers leads by text.
agent_name
body · string
First name the AI texts as (max 40).
reply_mode
body · string
suggest = drafts each reply for a teammate to approve (GET /v1/reply-suggestions).
One of: auto, suggest
first_message
body · string
First text to a new lead. Variables {first_name}, {agent}, {business}, {service}.
send_window
body · string
When automated texts may go out.
One of: anytime, daily, booking_hours
prompt_addition
body · string
A rule added to the end of the AI's prompt (max 2000).
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/ai-texting" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "ai_replies": true,
    "agent_name": "Sarah",
    "reply_mode": "auto",
    "prompt_addition": "Always mention free estimates."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-texting", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    ai_replies: true,
    agent_name: "Sarah",
    reply_mode: "auto",
    prompt_addition: "Always mention free estimates.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/ai-texting",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "ai_replies": True,
        "agent_name": "Sarah",
        "reply_mode": "auto",
        "prompt_addition": "Always mention free estimates.",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "ai_texting": {
      "ai_replies": true,
      "agent_name": "Sarah",
      "reply_mode": "auto",
      "send_window": "daily",
      "send_window_start": "08:00",
      "send_window_end": "21:00",
      "prompt_characters": 2140
    },
    "changed": [
      "ai_replies",
      "agent_name",
      "reply_mode",
      "prompt_addition"
    ]
  },
  "error": null
}
#

Read the automatic follow-up sequence

GET/v1/nurture-sequence
Read only

Every text and email step with the day it goes out, whether each channel is on, and offers whose own follow-ups replace it for the leads they match.

Request
cURL
curl "https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Replace the follow-up texts (or emails)

PUT/v1/nurture-sequence
Read and writeIdempotent

Replaces one channel's steps and turns it on. Timing is the app's: the first 3 hours after the first text, then front-loaded days over span_days (about 5 days per message, at most 60), or the explicit days you send. At most 40 texts or 60 emails; email needs a verified sending domain. Pending sends that would change channel are cancelled.

ParameterDescription
channel
body · string
Default sms.
One of: sms, email
messages
body · array · required
One message per step, in order.
subjects
body · array
Email only: one subject per message.
span_days
body · number
Days from the first message to the last (1-365).
days
body · array
Instead of span_days: the day each message goes out, going up. 0 = 3 hours in.
enabled
body · boolean
Default true.
Request
cURL
curl -X PUT "https://api.ottopros.com/v1/nurture-sequence" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "sms",
    "messages": [
      "Hi {first_name}, still looking for a quote?",
      "Quick check-in from {business}: want me to hold a time for you?"
    ],
    "span_days": 7
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/nurture-sequence", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    channel: "sms",
    messages: [
      "Hi {first_name}, still looking for a quote?",
      "Quick check-in from {business}: want me to hold a time for you?",
    ],
    span_days: 7,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.put(
    "https://api.ottopros.com/v1/nurture-sequence",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "channel": "sms",
        "messages": [
            "Hi {first_name}, still looking for a quote?",
            "Quick check-in from {business}: want me to hold a time for you?",
        ],
        "span_days": 7,
    },
)
print(res.json())
#

Switch follow-ups on or off, or reword one step

PATCH/v1/nurture-sequence
Read and writeIdempotent

Send enabled, or step with message (and subject for email). A reworded step keeps its timing.

ParameterDescription
channel
body · string
Default sms.
One of: sms, email
enabled
body · boolean
On or off.
step
body · number
Which step to reword (1 = first).
message
body · string
The step's new wording.
subject
body · string
Email only: the new subject.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/nurture-sequence" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "sms",
    "step": 2,
    "message": "Hi {first_name}, any questions I can answer?"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/nurture-sequence", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    channel: "sms",
    step: 2,
    message: "Hi {first_name}, any questions I can answer?",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/nurture-sequence",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "channel": "sms",
        "step": 2,
        "message": "Hi {first_name}, any questions I can answer?",
    },
)
print(res.json())
#

Remove one channel's follow-up steps

DELETE/v1/nurture-sequence
Read and writeIdempotent

Switches that channel off and cancels its pending sends.

ParameterDescription
channel
query · string
Default sms.
One of: sms, email
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/nurture-sequence?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

Dashboard

#

Dashboard numbers for a window

GET/v1/overview
Read only

New leads, booked leads and appointments (each with the previous window of the same length for comparison), upcoming appointments, contacted, won and closed, speed to lead (median minutes to the first reply: anyone, AI or automation, a person), AI replies sent, calls (answered, missed, voicemails, AI calls, talk time), and stage_reach: of the leads created in the window, how many reached each pipeline stage (total, and reached keyed by stage key from GET /v1/stages; a lead counts for every stage it has been in, so a lead that sat and then won counts for both; the Overview's "<Stage> rate" cards divide reached by total). Moves made before 2026-10-16 are not known. stage_reach is null if it could not be read. Viewers limited to their own leads see only those.

ParameterDescription
days
query · number
1-365, default 30.
from
query · string
ISO start, instead of days (window at most 366 days).
to
query · string
ISO end, default now.
Request
cURL
curl "https://api.ottopros.com/v1/overview?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/overview?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/overview?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace_id": "uuid",
    "window": {
      "from": "2026-08-31T00:00:00Z",
      "to": "2026-09-30T00:00:00Z",
      "days": 30
    },
    "stats": {
      "leads": 142,
      "prev_leads": 120,
      "booked_leads": 38,
      "prev_booked_leads": 31,
      "appointments": 41,
      "upcoming_appointments": 12,
      "contacted": 131,
      "won": 9,
      "closed": 15,
      "new_unworked": 4,
      "median_response_minutes": 0.8,
      "median_response_minutes_auto": 0.4,
      "median_response_minutes_human": 22.5,
      "ai_replies": {
        "total": 512,
        "sms": 498,
        "email": 9,
        "other_channels": 5,
        "leads": 96
      },
      "calls": {
        "total": 88,
        "inbound": 30,
        "outbound": 58,
        "answered": 61,
        "missed_inbound": 6,
        "voicemails": 9,
        "ai_calls": 40,
        "ai_receptionist_calls": 8,
        "talk_seconds": 15540
      },
      "stage_reach": {
        "total": 142,
        "reached": {
          "new": 142,
          "contacted": 131,
          "sat": 44,
          "booked": 38,
          "won": 9
        }
      }
    }
  },
  "error": null
}
#

Credits used by tool

GET/v1/usage
Read only

What the sub-account spent in a window, by tool (the Usage tab's names: texts, lead alert texts, email, AI text replies, website chat widget, AI agents, phone calls, AI receptionist, AI calling, phone numbers, ad tools) and by day. unpaid counts events that ran while the account was out of credits.

ParameterDescription
days
query · number
1-365, default 30.
from
query · string
ISO start, instead of days.
to
query · string
ISO end, default now.
Request
cURL
curl "https://api.ottopros.com/v1/usage?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/usage?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/usage?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace_id": "uuid",
    "window": {
      "from": "2026-08-31T00:00:00Z",
      "to": "2026-09-30T00:00:00Z",
      "days": 30
    },
    "total_credits": 318.5,
    "total_events": 2210,
    "unpaid_events": 0,
    "by_tool": [
      {
        "tool": "ai_text",
        "label": "AI text replies",
        "events": 512,
        "credits": 128,
        "unpaid": 0
      },
      {
        "tool": "messaging",
        "label": "Texts",
        "events": 1404,
        "credits": 105.3,
        "unpaid": 0
      }
    ],
    "by_day": [
      {
        "date": "2026-09-29",
        "events": 81,
        "credits": 11.5
      }
    ]
  },
  "error": null
}
#

The Sales page: won jobs, parts, tips and profit

GET/v1/sales
Read only

Settings > Features > Sales (off until an owner or admin turns it on: 403 feature_off until then). Won jobs in the window by the date they were won, as the Sales page adds them up: what customers paid, parts, tips and profit in total, per technician (deal_sold_by, else whoever the lead is assigned to), per lead source and per tag, plus the jobs themselves. Every group also has plans, monthly_cents (the monthly revenue its new plans add) and expected_cents (what its jobs are expected to bring in, one-time jobs at their amount), and each job its deal_type and plan (Plans & memberships). Record a sale with PATCH /v1/leads/{lead_id}: status won, deal_value_cents, deal_parts_cents, deal_tip_cents, deal_sold_by. Owners and admins only.

ParameterDescription
days
query · number
1-365, default 30.
from
query · string
ISO start, instead of days.
to
query · string
ISO end, default now.
Request
cURL
curl "https://api.ottopros.com/v1/sales?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/sales?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/sales?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

The Activity log

GET/v1/activity
Read only

Everything that happened in the sub-account, newest first, as Settings > Activity log shows it: every change (the fields that changed, before and after, secrets hidden) with who made it (a person, an API key, the AI or an automation), their IP and browser, who opened what (contacts, conversations, recordings, exports), and messages, calls, bookings, automation runs, notifications and errors. Keyset paged by next_cursor. summary=true adds the page's counts by type and by day and who did the most. Owners and admins only.

ParameterDescription
days
query · number
1-365, default 30.
from
query · string
ISO start, instead of days.
to
query · string
ISO end, default now.
category
query · string
Comma-separated: change, access, lead, inbound, outbound, ai, call, booking, appointment, automation, notification, webhook, api, error, system.
actor
query · string
A user id, or user, api, ai, automation, lead or system.
lead_id
query · string
Only what happened to this lead.
search
query · string
Words in the title or description, or a contact's name, phone or email.
summary
query · boolean
Also return counts by type and day and the most active people.
tz
query · string
IANA time zone for summary days, default UTC.
limit
query · number
1-200, default 50.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/activity?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/activity?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/activity?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace_id": "uuid",
    "window": {
      "from": "2026-09-24T00:00:00Z",
      "to": "2026-10-24T00:00:00Z",
      "days": 30
    },
    "activity": [
      {
        "id": "al:uuid",
        "occurred_at": "2026-10-24T15:02:11Z",
        "source": "al",
        "category": "change",
        "title": null,
        "description": "Changed the AI texting settings",
        "status": "ok",
        "lead_id": null,
        "lead_name": null,
        "lead_phone": null,
        "actor_id": "uuid",
        "actor_kind": "api",
        "actor_label": "API key · Zapier",
        "actor_name": "Pat Owner",
        "actor_email": "pat@example.com",
        "metadata": null,
        "audit": {
          "entity_table": "workspace_outreach_settings",
          "action": "update",
          "changed_fields": [
            "ai_reply_enabled"
          ],
          "old_values": {
            "ai_reply_enabled": false
          },
          "new_values": {
            "ai_reply_enabled": true
          },
          "ip_address": "203.0.113.4",
          "user_agent": "Zapier",
          "source": "api"
        }
      }
    ],
    "has_more": true,
    "next_cursor": "MjAyNi0xMC0yNFQxNTowMjoxMVp8YWw6dXVpZA"
  },
  "error": null
}

Inbox

#

The Inbox: conversations, newest first

GET/v1/conversations
Read only

Every lead with a message either way (or an unread one), like the Inbox: unread count, archive state, whether the AI is paused on it (and the question it handed to the team), and the last message or call. The first page also returns the Inbox counts. Read the thread with GET /v1/leads/{lead_id}/conversation.

ParameterDescription
archived
query · boolean
true = the Archived tab. Default false.
unread
query · boolean
Only unread conversations.
assigned_to
query · string
A user id, "me" or "unassigned".
search
query · string
Name, email or phone contains (2-80 characters).
imessage_line_id
query · string
Only the conversations this iMessage Mac has had (GET /v1/imessage/lines), like the Inbox's number filter.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/conversations?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/conversations?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/conversations?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "conversations": [
      {
        "lead_id": "uuid",
        "workspace_id": "uuid",
        "name": "Jane Doe",
        "phone": "+14155550123",
        "email": null,
        "status": "contacted",
        "stage_key": "contacted",
        "assigned_to": null,
        "unread": true,
        "unread_count": 2,
        "archived": false,
        "ai": {
          "paused": false,
          "paused_until": null,
          "pause_reason": null,
          "handoff_question": null
        },
        "channel": "sms",
        "last_message": {
          "id": "uuid",
          "channel": "sms",
          "direction": "inbound",
          "body": "Can you come Thursday?",
          "subject": null,
          "sent_by": null,
          "created_at": "2026-09-30T15:02:00Z"
        },
        "last_inbound_at": "2026-09-30T15:02:00Z",
        "last_outbound_at": "2026-09-30T14:40:00Z",
        "last_activity_at": "2026-09-30T15:02:00Z"
      }
    ],
    "has_more": true,
    "next_cursor": "MjAyNi0wOS0zMFQxNTowMjowMCswMDowMHx1dWlk",
    "counts": {
      "open": 210,
      "unread": 7,
      "archived": 54
    }
  },
  "error": null
}
#

Mark a conversation read

POST/v1/conversations/{lead_id}/read
Read and writeIdempotent

Anyone who can see the lead can, as in the Inbox.

ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/conversations/LEAD_ID/read" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/conversations/LEAD_ID/read", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/conversations/LEAD_ID/read",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Mark a conversation unread

POST/v1/conversations/{lead_id}/unread
Read and writeIdempotent
ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/conversations/LEAD_ID/unread" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/conversations/LEAD_ID/unread", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/conversations/LEAD_ID/unread",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Archive a conversation

POST/v1/conversations/{lead_id}/archive
Read and writeIdempotent

As in the app, archiving switches the lead's automatic follow-up texts off. A new message from the lead brings the conversation back.

ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/conversations/LEAD_ID/archive" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/conversations/LEAD_ID/archive", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/conversations/LEAD_ID/archive",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Move a conversation back to the Inbox

POST/v1/conversations/{lead_id}/unarchive
Read and writeIdempotent

Follow-ups switched off by archiving stay off.

ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/conversations/LEAD_ID/unarchive" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/conversations/LEAD_ID/unarchive", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/conversations/LEAD_ID/unarchive",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Pause or resume the AI on one lead

POST/v1/leads/{lead_id}/ai-pause
Read and writeIdempotent

paused: true stops AI replies to this lead (and its automatic follow-ups at send time) until someone turns it back on, or until until. paused: false turns it back on and clears any hand-off. The sub-account-wide switch is ai_replies on PATCH /v1/ai-texting.

ParameterDescription
lead_id
path · string · required
Lead id.
paused
body · boolean · required
Pause (true) or resume (false).
until
body · string
ISO time the AI turns itself back on (paused: true only, at most a year away).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/leads/LEAD_ID/ai-pause" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "paused": true,
    "until": "2026-10-07T15:00:00Z"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/ai-pause", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    paused: true,
    until: "2026-10-07T15:00:00Z",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads/LEAD_ID/ai-pause",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "paused": True,
        "until": "2026-10-07T15:00:00Z",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead_id": "uuid",
    "ai": {
      "paused": true,
      "paused_until": "2026-10-07T15:00:00.000Z",
      "pause_reason": "Paused from the API",
      "handoff_question": null
    }
  },
  "error": null
}
#

AI suggested replies waiting for a teammate

GET/v1/reply-suggestions
Read only

When AI texting's reply_mode is suggest, the AI drafts its reply to each text or email here instead of sending it. Each one shows the lead, the draft, and the appointment it would book or cancel. reply_mode in the response says whether the sub-account is in suggest mode.

ParameterDescription
status
query · string
Default pending.
One of: pending, sending, sent, dismissed, superseded, failed, all
lead_id
query · string
One lead's suggestions.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/reply-suggestions?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/reply-suggestions?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/reply-suggestions?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "reply_mode": "suggest",
    "suggestions": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "channel": "sms",
        "body": "Thursday at 2pm works! I've put you down, see you then.",
        "status": "pending",
        "error": null,
        "inbound_at": "2026-09-30T15:02:00Z",
        "sent_body": null,
        "decided_at": null,
        "created_at": "2026-09-30T15:02:20Z",
        "updated_at": "2026-09-30T15:02:20Z",
        "books": {
          "starts_at": "2026-10-02T19:00:00Z",
          "ends_at": "2026-10-02T20:00:00Z",
          "title": "Roof inspection",
          "timezone": "America/New_York",
          "location": null,
          "is_arrival_window": false
        },
        "cancels": null,
        "lead": {
          "id": "uuid",
          "full_name": "Jane Doe",
          "phone": "+14155550123",
          "email": null
        }
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

Dismiss a suggested reply

POST/v1/reply-suggestions/{suggestion_id}/dismiss
Read and writeIdempotent

Nothing is sent. The AI does not suggest again until the lead writes. 409 when it was already handled.

ParameterDescription
suggestion_id
path · string · required
Suggestion id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/dismiss" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/dismiss", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/dismiss",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Send a suggested reply

POST/v1/reply-suggestions/{suggestion_id}/approve
Read and writeUses creditsIdempotent

Sends it to the lead as the AI (edited, if you send body), makes the booking or cancellation it decided on, and is charged exactly like an automatic AI reply plus the text, through the same code as the Inbox's Send button. Owners and admins only. 409 = someone already answered or the lead wrote again (nothing sent), 422 = not sent for a reason in error (e.g. AI paused, out of credits, that time is no longer open), 503 = a sending hiccup, try again.

ParameterDescription
suggestion_id
path · string · required
Suggestion id.
body
body · string
The edited reply to send instead (texts up to 1600 characters, emails 5000).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/approve" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "body": "Thursday at 2pm works! See you then."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/approve", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    body: "Thursday at 2pm works! See you then.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/reply-suggestions/SUGGESTION_ID/approve",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "body": "Thursday at 2pm works! See you then.",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "suggestion_id": "uuid",
    "sent": true,
    "status": "sent",
    "booked": true,
    "cancelled": false
  },
  "error": null
}

Calls

#

Phone calls, newest first

GET/v1/calls
Read only

Every call on the sub-account's numbers: direction, type (softphone, bridge, inbound, forward, ai, ai_receptionist), status, duration, voicemail, the AI's outcome and summary, and the lead. Get one for its transcript and recording.

ParameterDescription
direction
query · string
Inbound or outbound.
One of: inbound, outbound
type
query · string
Call type.
One of: softphone, bridge, inbound, forward, ai, ai_receptionist
outcome
query · string
The Calls tab's outcome filter.
One of: connected, missed, voicemail
lead_id
query · string
One lead's calls.
since
query · string
ISO time, calls at or after.
until
query · string
ISO time, calls before.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/calls?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calls?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calls?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "calls": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "direction": "inbound",
        "mode": "ai_receptionist",
        "status": "completed",
        "from_number": "+14155550123",
        "to_number": "+14155550199",
        "answered_at": "2026-09-30T15:00:04Z",
        "ended_at": "2026-09-30T15:03:10Z",
        "duration_sec": 186,
        "is_voicemail": false,
        "voicemail_read_at": null,
        "ai_outcome": "booked",
        "ai_summary": "Caller wanted a roof inspection; booked Thursday 2pm.",
        "ai_booking_id": "uuid",
        "campaign_id": null,
        "created_at": "2026-09-30T15:00:00Z",
        "ai_call": true,
        "has_recording": true,
        "lead": {
          "id": "uuid",
          "full_name": "Jane Doe",
          "phone": "+14155550123"
        }
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

One call: summary, transcript and recording

GET/v1/calls/{call_id}
Read only

recording.url is a fresh link that works for 15 minutes; ask again for a new one.

ParameterDescription
call_id
path · string · required
Call id.
Request
cURL
curl "https://api.ottopros.com/v1/calls/CALL_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calls/CALL_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calls/CALL_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "call": {
      "id": "uuid",
      "direction": "inbound",
      "mode": "ai_receptionist",
      "status": "completed",
      "duration_sec": 186,
      "ai_outcome": "booked",
      "ai_summary": "Caller wanted a roof inspection; booked Thursday 2pm.",
      "transcript": "AI: Thanks for calling...\nCaller: Hi, I need...",
      "recording": {
        "url": "https://.../lead-media/...mp3?token=...",
        "expires_at": "2026-09-30T15:20:00Z",
        "expires_in_seconds": 900
      },
      "lead": {
        "id": "uuid",
        "full_name": "Jane Doe",
        "phone": "+14155550123"
      }
    }
  },
  "error": null
}
#

AI calling campaigns and their progress

GET/v1/call-campaigns
Read only

Leads, calls placed and connected, bookings, and credits used against the spend cap. Owners and admins only.

ParameterDescription
status
query · string
Only this status.
One of: draft, running, paused, completed
Request
cURL
curl "https://api.ottopros.com/v1/call-campaigns?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/call-campaigns?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/call-campaigns?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Pause a running AI calling campaign

POST/v1/call-campaigns/{campaign_id}/pause
Read and writeIdempotent

Its queued calls are cancelled. Resuming places paid calls, so it is done in the app. 409 when it is not running.

ParameterDescription
campaign_id
path · string · required
Campaign id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/call-campaigns/CAMPAIGN_ID/pause" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/call-campaigns/CAMPAIGN_ID/pause", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/call-campaigns/CAMPAIGN_ID/pause",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

AI Brain

#

What the team has taught the AI

GET/v1/ai-lessons
Read only

Settings → AI Brain: rules (instructions every AI reply follows), good examples (thumbs up) and lessons (thumbs down: what was wrong, a better reply, never send anything like it again). The AI texter and the voice agent read the active ones on every reply. Owners and admins only.

ParameterDescription
kind
query · string
Only this kind.
One of: good, avoid, rule
active
query · boolean
Only switched on (true) or off (false).
limit
query · number
1-100, default 50.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/ai-lessons?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-lessons?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ai-lessons?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lessons": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "kind": "rule",
        "channel": "sms",
        "ai_message": null,
        "lead_message": null,
        "better_reply": null,
        "reasons": [],
        "note": "Never quote prices by text.",
        "never_again": false,
        "source": "brain",
        "lead_id": null,
        "active": true,
        "created_at": "2026-09-30T12:00:00Z",
        "updated_at": "2026-09-30T12:00:00Z",
        "message_id": null
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

Add a rule the AI follows

POST/v1/ai-lessons
Read and writeIdempotent

Like Add rule in Settings → AI Brain. channel limits it to texts (sms), email or calls (voice); leave it out for everywhere. To teach from a specific reply, rate it (POST /v1/messages/{message_id}/rating). Owners and admins only; at most 200 rules.

ParameterDescription
note
body · string · required
The rule, max 600 characters.
channel
body · string
Where it applies. Default everywhere.
One of: sms, email, voice, all
active
body · boolean
Default true.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/ai-lessons" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "note": "Never quote prices by text; offer a free estimate visit instead.",
    "channel": "sms"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-lessons", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    note: "Never quote prices by text; offer a free estimate visit instead.",
    channel: "sms",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/ai-lessons",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "note": "Never quote prices by text; offer a free estimate visit instead.",
        "channel": "sms",
    },
)
print(res.json())
#

Switch a lesson off or reword it

PATCH/v1/ai-lessons/{lesson_id}
Read and writeIdempotent

What can change depends on the kind, as in the app: a rule's note, channel and active; a thumbs-down lesson's note, better_reply, never_again and active; a good example's active only.

ParameterDescription
lesson_id
path · string · required
Lesson id.
active
body · boolean
On or off.
note
body · string
Max 600.
channel
body · string
Rules only.
One of: sms, email, voice, all
better_reply
body · string
Thumbs-down lessons only, max 2000.
never_again
body · boolean
Thumbs-down lessons only.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/ai-lessons/LESSON_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "active": false
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-lessons/LESSON_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    active: false,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/ai-lessons/LESSON_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "active": False,
    },
)
print(res.json())
#

Delete a lesson for good

DELETE/v1/ai-lessons/{lesson_id}
Read and writeIdempotent

active: false on PATCH keeps it but switches it off.

ParameterDescription
lesson_id
path · string · required
Lesson id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/ai-lessons/LESSON_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ai-lessons/LESSON_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/ai-lessons/LESSON_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Thumbs up or down on an AI message

POST/v1/messages/{message_id}/rating
Read and writeIdempotent

Exactly the Inbox's rating. up keeps the reply as a good example; down records what was wrong (reasons), what it should have said (better_reply), a note, and by default never to send anything close again; clear removes the rating. Rating the same message again replaces its lesson. Works on messages and calls sent to the lead (message ids from GET /v1/leads/{lead_id}/conversation). Owners and admins only.

ParameterDescription
message_id
path · string · required
The message (lead event) id.
rating
body · string · required
up, down or clear.
One of: up, down, clear
reasons
body · array
down only: wrong_info, too_pushy, too_long, missed_question, wrong_tone, should_not_reply.
better_reply
body · string
down only: what it should have said (max 2000).
note
body · string
down only: a note (max 600).
never_again
body · boolean
down only. Default true.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/messages/MESSAGE_ID/rating" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "rating": "down",
    "reasons": [
      "too_pushy"
    ],
    "better_reply": "No rush at all, happy to answer any questions first."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages/MESSAGE_ID/rating", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    rating: "down",
    reasons: ["too_pushy"],
    better_reply: "No rush at all, happy to answer any questions first.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/messages/MESSAGE_ID/rating",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "rating": "down",
        "reasons": ["too_pushy"],
        "better_reply": "No rush at all, happy to answer any questions first.",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "message_id": "uuid",
    "rating": "down",
    "lesson_id": "uuid",
    "lesson": {
      "id": "uuid",
      "kind": "avoid",
      "channel": "sms",
      "ai_message": "Book now before slots fill up!",
      "better_reply": "No rush at all, happy to answer any questions first.",
      "reasons": [
        "too_pushy"
      ],
      "never_again": true,
      "active": true
    }
  },
  "error": null
}

CRM leads

#

List and filter leads

GET/v1/leads
Read only

Newest first. Filter by tags (tag_match=any returns leads with at least one of the tags, all leads with every one), status, pipeline stage, assignee, a name / email / phone search and a created-since time. Each lead carries inquiry (the CRM's Inquiry column), address, city, state, zip, custom_fields, tags and stage (the pipeline stage it shows in). Page with limit and offset: next_offset is set while there are more.

ParameterDescription
tags
query · string
Tag names, comma-separated (tags=VIP,Roof), or a JSON array (tags=["Roof, metal","VIP"]) when a name has a comma. Max 10.
tag_match
query · string
any (default): at least one of the tags. all: every tag.
One of: any, all. Default: any
status
query · string
One or more statuses, comma-separated: new, contacted, qualified, booked, won, lost, spam, partial.
stage_key
query · string
Pipeline stage key from GET /v1/stages.
assigned_to
query · string
A user id (GET /v1/members): leads assigned to them. "me": the key's own user. "unassigned": nobody's.
search
query · string
Matches name, email or phone (any format).
since
query · string
ISO timestamp: only leads created at or after it.
limit
query · number
Default 50, max 200.
offset
query · number
Skip this many leads; pass next_offset from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/leads" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/leads",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "leads": [
      {
        "id": "lead-uuid",
        "workspace_id": "ws-uuid",
        "full_name": "Jane Doe",
        "email": "jane@example.com",
        "phone": "+14155550123",
        "source": "api",
        "status": "contacted",
        "stage_key": "quote_sent",
        "score": null,
        "assigned_to": null,
        "ad_name": null,
        "campaign_name": null,
        "deal_value_cents": null,
        "deal_currency": "USD",
        "created_at": "2026-09-23T15:00:00Z",
        "updated_at": "2026-09-23T15:05:00Z",
        "last_activity_at": "2026-09-23T15:05:00Z",
        "stage": "quote_sent",
        "inquiry": "Roof replacement quote",
        "address": "12 Main St",
        "city": "Pittsburgh",
        "state": "PA",
        "zip": "15213",
        "custom_fields": {
          "roof_type": "Metal",
          "roof_age_years": 18
        },
        "tags": [
          "Spring promo",
          "VIP"
        ]
      }
    ],
    "has_more": false,
    "next_offset": null
  },
  "error": null
}
#

Get one lead

GET/v1/leads/{lead_id}
Read only

The lead with every column, its raw_payload, the flattened inquiry, address, city, state, zip, custom_fields, tags and stage, plus its 50 most recent timeline events.

ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl "https://api.ottopros.com/v1/leads/LEAD_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/leads/LEAD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead": {
      "id": "lead-uuid",
      "workspace_id": "ws-uuid",
      "full_name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+14155550123",
      "source": "api",
      "status": "contacted",
      "stage_key": "quote_sent",
      "score": null,
      "assigned_to": null,
      "ad_name": null,
      "campaign_name": null,
      "deal_value_cents": null,
      "deal_currency": "USD",
      "created_at": "2026-09-23T15:00:00Z",
      "updated_at": "2026-09-23T15:05:00Z",
      "last_activity_at": "2026-09-23T15:05:00Z",
      "stage": "quote_sent",
      "inquiry": "Roof replacement quote",
      "address": "12 Main St",
      "city": "Pittsburgh",
      "state": "PA",
      "zip": "15213",
      "custom_fields": {
        "roof_type": "Metal",
        "roof_age_years": 18
      },
      "tags": [
        "Spring promo",
        "VIP"
      ]
    },
    "events": [
      {
        "id": "event-uuid",
        "event_type": "note",
        "body": "Lead created via API",
        "created_at": "2026-09-23T15:00:00Z"
      }
    ]
  },
  "error": null
}
#

Create a lead

POST/v1/leads
Read and writeIdempotent

Needs workspace_id plus at least one of full_name, email or phone. Besides the lead's columns you can set inquiry, address, city, state, zip, custom_fields and tags. The lead and its tags are saved together, so automations that filter on tags see them; each tag fires the lead.tag_added automation trigger. A lead with the same phone (last 10 digits) or email already in the sub-account answers 409 with its lead_id and matched_on in details: update that lead instead. A field the API does not know answers 400 (it is never silently dropped). By default the intake doesn't run (no first text or AI opener, follow-ups, team alert or Instant Lead Connect); run_intake: true works it like any new lead from a form and intake in the response says what ran. Either way it is a new lead to the rest of the CRM, as one added by hand is: automations that start on a new lead or a tag run, AI calling campaigns that take new leads pick it up, and the lead.created webhook fires. Returns the lead as GET /v1/leads/{lead_id} shows it.

ParameterDescription
full_name
body · string
Lead name.
email
body · string
Email address.
phone
body · string
10-digit US number or + and a country code; stored as E.164.
source
body · string
Where the lead came from. Defaults to api.
One of: meta_lead_ad, manual, website_form, import, api, other, sms_inbound, email_inbound, webhook, smart_form, smart_form_partial, site_widget, instagram, messenger, review_funnel, calendly, acuity
status
body · string
Base lead status. To move a lead in the pipeline, send stage_key instead.
One of: new, contacted, qualified, booked, won, lost, spam, partial
stage_key
body · string
Pipeline stage key from GET /v1/stages. A stage also sets the status it stands for.
score
body · number
A number 0-100 that is yours to set, e.g. a counter your automations keep. Nothing writes it for you (AI lead scoring is off).
notes
body · string
Internal notes.
assigned_to
body · string
User id of someone in the sub-account (GET /v1/members). The agency owner only with the owner's own key (the app never offers them to the sub-account's people; a lead already on them may stay). null unassigns. A change is logged on the lead's timeline (Lead assigned / Lead unassigned), as in the app.
ad_name
body · string
Ad the lead came from.
campaign_name
body · string
Campaign the lead came from.
utm_source
body · string
UTM source.
utm_medium
body · string
UTM medium.
utm_campaign
body · string
UTM campaign.
utm_term
body · string
UTM term.
utm_content
body · string
UTM content.
landing_url
body · string
Page the lead converted on.
referrer_url
body · string
Referring page.
attribution_channel
body · string
e.g. meta, google, organic, direct.
gclid
body · string
Google Ads click id. A lead with one is sent back to the sub-account's Google Ads by its click when it books or is won (CRM conversions).
gbraid
body · string
Google Ads app-to-web click id.
wbraid
body · string
Google Ads web-to-app click id.
fbclid
body · string
Facebook click id.
msclkid
body · string
Microsoft Ads click id.
deal_value_cents
body · number
Deal value in cents: on a won job, what the customer paid (the Sales report's Sold, and the revenue sent to Meta).
deal_currency
body · string
3-letter currency code. Default USD.
deal_parts_cents
body · number
Won job: what the parts cost, in cents (Sales report).
deal_tip_cents
body · number
Won job: the tip the technician got, in cents.
deal_sold_by
body · string
Won job: the user id of the technician credited with the sale (GET /v1/members). null = whoever the lead is assigned to.
deal_won_at
body · string
When the job was won (ISO 8601). Set for you when status becomes won; send it to date a sale yourself.
deal_type
body · string
Plans & memberships: what the customer bought, one_time (a job) or plan (a plan or membership that pays again). null = one-time. A deal_plan_cents sent alone makes it a plan; one_time clears the plan.
One of: one_time, plan
deal_plan_cents
body · number
Plan: its price in cents, charged every deal_plan_interval. deal_value_cents is the first payment (the plan price when you leave it out).
deal_plan_interval
body · string
Plan: how often it's charged. Default month.
One of: month, quarter, year
deal_plan_start_date
body · string
Plan: when it starts (YYYY-MM-DD). Default today.
deal_plan_months
body · number
Plan: how many months it runs (1-600). null = no end date: the business's default length counts (12 months unless changed in Settings > Features). Leads return deal_monthly_cents and deal_expected_total_cents (first payment + the plan price for the rest of its length).
inquiry
body · string
What the lead asked about: the Inquiry column in the CRM (max 500). Without it the CRM shows the form or ad offer, then General inquiry.
address
body · string
Street address.
city
body · string
City.
state
body · string
State or region. A US state, Canadian province or Australian state is stored as its code ("Texas" -> "TX"), a Mexican state by name, like every other intake; the list follows the lead's country (its phone number, else the sub-account's country). Anything else is kept as sent.
zip
body · string
ZIP or postal code.
custom_fields
body · object
Custom field values keyed by field_key (GET /v1/custom-fields); a field's label also works. Values are checked against the field type: number/money/rating numbers, boolean true/false, date YYYY-MM-DD, datetime ISO 8601, select one of its options, multiselect a list of options. null or "" clears a value; fields you leave out keep their value.
tags
body · array
Tag names. New names are created in the sub-account (matched ignoring case). Max 50.
run_intake
body · boolean
Default false. true = work the lead like a new lead from a form: first text or AI opener, follow-ups, team alert, Instant Lead Connect.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/leads" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "full_name": "Jane Doe",
    "phone": "+14155550123",
    "email": "jane@example.com",
    "inquiry": "Roof replacement quote",
    "address": "12 Main St",
    "city": "Pittsburgh",
    "state": "PA",
    "zip": "15213",
    "custom_fields": {
      "roof_type": "Metal",
      "roof_age_years": 18
    },
    "tags": [
      "Spring promo",
      "VIP"
    ],
    "run_intake": true
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    full_name: "Jane Doe",
    phone: "+14155550123",
    email: "jane@example.com",
    inquiry: "Roof replacement quote",
    address: "12 Main St",
    city: "Pittsburgh",
    state: "PA",
    zip: "15213",
    custom_fields: {
      roof_type: "Metal",
      roof_age_years: 18,
    },
    tags: ["Spring promo", "VIP"],
    run_intake: true,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "full_name": "Jane Doe",
        "phone": "+14155550123",
        "email": "jane@example.com",
        "inquiry": "Roof replacement quote",
        "address": "12 Main St",
        "city": "Pittsburgh",
        "state": "PA",
        "zip": "15213",
        "custom_fields": {
            "roof_type": "Metal",
            "roof_age_years": 18,
        },
        "tags": ["Spring promo", "VIP"],
        "run_intake": True,
    },
)
print(res.json())
Response 201
JSON
{
  "success": true,
  "data": {
    "lead": {
      "id": "lead-uuid",
      "workspace_id": "ws-uuid",
      "full_name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+14155550123",
      "source": "api",
      "status": "contacted",
      "stage_key": "quote_sent",
      "score": null,
      "assigned_to": null,
      "ad_name": null,
      "campaign_name": null,
      "deal_value_cents": null,
      "deal_currency": "USD",
      "created_at": "2026-09-23T15:00:00Z",
      "updated_at": "2026-09-23T15:05:00Z",
      "last_activity_at": "2026-09-23T15:05:00Z",
      "stage": "quote_sent",
      "inquiry": "Roof replacement quote",
      "address": "12 Main St",
      "city": "Pittsburgh",
      "state": "PA",
      "zip": "15213",
      "custom_fields": {
        "roof_type": "Metal",
        "roof_age_years": 18
      },
      "tags": [
        "Spring promo",
        "VIP"
      ]
    },
    "intake": {
      "ran": true,
      "greeted": true
    }
  },
  "error": null
}
#

Update a lead

PATCH/v1/leads/{lead_id}
Read and writeIdempotent

Send only what changes; null or "" clears a value. custom_fields merges: values you leave out are kept. tags REPLACES all of the lead's tags; add_tags and remove_tags change only those (applied in that order: tags, add_tags, remove_tags). A body with only tag changes is fine. stage_key moves the lead in the pipeline and sets the status that stage stands for. A field the API does not know answers 400. Returns the updated lead.

ParameterDescription
lead_id
path · string · required
Lead id.
full_name
body · string
Lead name.
email
body · string
Email address.
phone
body · string
10-digit US number or + and a country code; stored as E.164.
source
body · string
Where the lead came from. Defaults to api.
One of: meta_lead_ad, manual, website_form, import, api, other, sms_inbound, email_inbound, webhook, smart_form, smart_form_partial, site_widget, instagram, messenger, review_funnel, calendly, acuity
status
body · string
Base lead status. To move a lead in the pipeline, send stage_key instead.
One of: new, contacted, qualified, booked, won, lost, spam, partial
stage_key
body · string
Pipeline stage key from GET /v1/stages. A stage also sets the status it stands for.
score
body · number
A number 0-100 that is yours to set, e.g. a counter your automations keep. Nothing writes it for you (AI lead scoring is off).
notes
body · string
Internal notes.
assigned_to
body · string
User id of someone in the sub-account (GET /v1/members). The agency owner only with the owner's own key (the app never offers them to the sub-account's people; a lead already on them may stay). null unassigns. A change is logged on the lead's timeline (Lead assigned / Lead unassigned), as in the app.
ad_name
body · string
Ad the lead came from.
campaign_name
body · string
Campaign the lead came from.
utm_source
body · string
UTM source.
utm_medium
body · string
UTM medium.
utm_campaign
body · string
UTM campaign.
utm_term
body · string
UTM term.
utm_content
body · string
UTM content.
landing_url
body · string
Page the lead converted on.
referrer_url
body · string
Referring page.
attribution_channel
body · string
e.g. meta, google, organic, direct.
gclid
body · string
Google Ads click id. A lead with one is sent back to the sub-account's Google Ads by its click when it books or is won (CRM conversions).
gbraid
body · string
Google Ads app-to-web click id.
wbraid
body · string
Google Ads web-to-app click id.
fbclid
body · string
Facebook click id.
msclkid
body · string
Microsoft Ads click id.
deal_value_cents
body · number
Deal value in cents: on a won job, what the customer paid (the Sales report's Sold, and the revenue sent to Meta).
deal_currency
body · string
3-letter currency code. Default USD.
deal_parts_cents
body · number
Won job: what the parts cost, in cents (Sales report).
deal_tip_cents
body · number
Won job: the tip the technician got, in cents.
deal_sold_by
body · string
Won job: the user id of the technician credited with the sale (GET /v1/members). null = whoever the lead is assigned to.
deal_won_at
body · string
When the job was won (ISO 8601). Set for you when status becomes won; send it to date a sale yourself.
deal_type
body · string
Plans & memberships: what the customer bought, one_time (a job) or plan (a plan or membership that pays again). null = one-time. A deal_plan_cents sent alone makes it a plan; one_time clears the plan.
One of: one_time, plan
deal_plan_cents
body · number
Plan: its price in cents, charged every deal_plan_interval. deal_value_cents is the first payment (the plan price when you leave it out).
deal_plan_interval
body · string
Plan: how often it's charged. Default month.
One of: month, quarter, year
deal_plan_start_date
body · string
Plan: when it starts (YYYY-MM-DD). Default today.
deal_plan_months
body · number
Plan: how many months it runs (1-600). null = no end date: the business's default length counts (12 months unless changed in Settings > Features). Leads return deal_monthly_cents and deal_expected_total_cents (first payment + the plan price for the rest of its length).
inquiry
body · string
What the lead asked about: the Inquiry column in the CRM (max 500). Without it the CRM shows the form or ad offer, then General inquiry.
address
body · string
Street address.
city
body · string
City.
state
body · string
State or region. A US state, Canadian province or Australian state is stored as its code ("Texas" -> "TX"), a Mexican state by name, like every other intake; the list follows the lead's country (its phone number, else the sub-account's country). Anything else is kept as sent.
zip
body · string
ZIP or postal code.
custom_fields
body · object
Custom field values keyed by field_key (GET /v1/custom-fields); a field's label also works. Values are checked against the field type: number/money/rating numbers, boolean true/false, date YYYY-MM-DD, datetime ISO 8601, select one of its options, multiselect a list of options. null or "" clears a value; fields you leave out keep their value.
tags
body · array
REPLACES all of the lead's tags ([] removes them all). To change only some, use add_tags / remove_tags.
add_tags
body · array
Tag names to add; the lead's other tags stay. New names are created.
remove_tags
body · array
Tag names to remove.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/leads/LEAD_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "add_tags": [
      "Hot lead"
    ],
    "remove_tags": [
      "Cold"
    ],
    "stage_key": "quote_sent",
    "custom_fields": {
      "roof_type": "Tile"
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    add_tags: ["Hot lead"],
    remove_tags: ["Cold"],
    stage_key: "quote_sent",
    custom_fields: {
      roof_type: "Tile",
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/leads/LEAD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "add_tags": ["Hot lead"],
        "remove_tags": ["Cold"],
        "stage_key": "quote_sent",
        "custom_fields": {
            "roof_type": "Tile",
        },
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead": {
      "id": "lead-uuid",
      "workspace_id": "ws-uuid",
      "full_name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+14155550123",
      "source": "api",
      "status": "contacted",
      "stage_key": "quote_sent",
      "score": null,
      "assigned_to": null,
      "ad_name": null,
      "campaign_name": null,
      "deal_value_cents": null,
      "deal_currency": "USD",
      "created_at": "2026-09-23T15:00:00Z",
      "updated_at": "2026-09-23T15:05:00Z",
      "last_activity_at": "2026-09-23T15:05:00Z",
      "stage": "quote_sent",
      "inquiry": "Roof replacement quote",
      "address": "12 Main St",
      "city": "Pittsburgh",
      "state": "PA",
      "zip": "15213",
      "custom_fields": {
        "roof_type": "Metal",
        "roof_age_years": 18
      },
      "tags": [
        "Spring promo",
        "VIP"
      ]
    }
  },
  "error": null
}
#

Delete a lead

DELETE/v1/leads/{lead_id}
Full accessIdempotent

With delete_appointments=true its appointments go first, each the way the calendar deletes one: the synced Google / Outlook event, the customer's invite (cancelled if it was still on), its reminder texts, then the appointment; if one can't be deleted the lead is kept and the answer says how many went. Without it the appointments stay on the calendar with no lead, as before.

ParameterDescription
lead_id
path · string · required
Lead id.
delete_appointments
query · boolean
Also delete the lead's appointments (the app's "Also delete their appointments").
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/leads/LEAD_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/leads/LEAD_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "deleted": true,
    "id": "uuid",
    "appointments_deleted": 2
  },
  "error": null
}
#

Lead activity timeline

GET/v1/leads/{lead_id}/timeline
Read only
ParameterDescription
lead_id
path · string · required
Lead id.
limit
query · number
Default 50.
Request
cURL
curl "https://api.ottopros.com/v1/leads/LEAD_ID/timeline" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/timeline", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/leads/LEAD_ID/timeline",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Add a note to a lead

POST/v1/leads/{lead_id}/notes
Read and writeIdempotent

mentions tags teammates like @Name in the app's note box: each one on the sub-account's team who can see the lead gets an email and a push with the note and a link to the conversation (replies go to you); the answer lists who was told and who was skipped and why (not_on_team, cant_see_lead).

ParameterDescription
lead_id
path · string · required
Lead id.
body
body · string · required
Note text.
actor_label
body · string
Who the note is from.
mentions
body · array
Up to 20 user ids to tag (GET /v1/members).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/leads/LEAD_ID/notes" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "body": "@Sam can you call her back after 5?",
    "mentions": [
      "uuid"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/notes", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    body: "@Sam can you call her back after 5?",
    mentions: ["uuid"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads/LEAD_ID/notes",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "body": "@Sam can you call her back after 5?",
        "mentions": ["uuid"],
    },
)
print(res.json())
Response 201
JSON
{
  "success": true,
  "data": {
    "event": {
      "id": "uuid",
      "event_type": "note",
      "body": "@Sam can you call her back after 5?"
    },
    "mentions": {
      "tagged": [
        {
          "user_id": "uuid",
          "name": "Sam Tech"
        }
      ],
      "skipped": []
    }
  },
  "error": null
}
#

Move a lead to a pipeline stage

POST/v1/leads/{lead_id}/stage
Read and writeIdempotent

PATCH on the same path does the same.

ParameterDescription
lead_id
path · string · required
Lead id.
stage_key
body · string · required
Stage key from GET /v1/stages.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/leads/LEAD_ID/stage" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "stage_key": "booked"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/stage", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    stage_key: "booked",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads/LEAD_ID/stage",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "stage_key": "booked",
    },
)
print(res.json())
#

List pipeline stages

GET/v1/stages
Read only
Request
cURL
curl "https://api.ottopros.com/v1/stages?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/stages?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/stages?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

List lead tags

GET/v1/tags
Read only

Every tag in the sub-account and how many leads carry it. A tag is created automatically the first time you send its name in a lead's tags or add_tags.

Request
cURL
curl "https://api.ottopros.com/v1/tags?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/tags?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/tags?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "tags": [
      {
        "id": "tag-uuid",
        "name": "VIP",
        "color": "slate",
        "lead_count": 12,
        "created_at": "2026-09-01T10:00:00Z"
      }
    ]
  },
  "error": null
}
#

Edit a note you wrote

PATCH/v1/notes/{note_id}
Read and writeIdempotent

Note ids are the note events in GET /v1/leads/{lead_id}/timeline. People can edit only their own notes, as in the app.

ParameterDescription
note_id
path · string · required
Note id.
body
body · string · required
The note, max 4000.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/notes/NOTE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "body": "Prefers a call after 5pm."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/notes/NOTE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    body: "Prefers a call after 5pm.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/notes/NOTE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "body": "Prefers a call after 5pm.",
    },
)
print(res.json())
#

Delete a note

DELETE/v1/notes/{note_id}
Read and writeIdempotent

Owners and admins.

ParameterDescription
note_id
path · string · required
Note id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/notes/NOTE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/notes/NOTE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/notes/NOTE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Mark a lead do-not-contact

POST/v1/leads/{lead_id}/do-not-contact
Read and writeIdempotent

Like Do not contact on the Leads page: no more emails, and texts to the lead's number are blocked. The app has no undo, so it takes an owner or admin.

ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/leads/LEAD_ID/do-not-contact" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/do-not-contact", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/leads/LEAD_ID/do-not-contact",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead_id": "uuid",
    "do_not_contact": true,
    "emails_blocked": true,
    "texts_blocked": true
  },
  "error": null
}

Messaging

#

Send an SMS or email

POST/v1/messages
Read and writeIdempotent

Sends from the sub-account's own number / verified sending domain and logs the message on the lead timeline. Email requires a subject. Address the message with lead_id, or with a raw to (E.164 phone for SMS, email address for email) plus workspace_id — CRM matches an existing contact or creates one. Every send counts against the daily unique-contact safety limit (300 contacts per UTC day by default); replies to contacts already messaged today are free of the cap. Over the cap the call answers 429. Each message is billed at the platform rate and counts against the key's monthly credit budget, if it has one (402 when the budget is used up); a text through one of the sub-account's iMessage Macs is free. The message is sent as the key's user, as from the Inbox: the conversation names them, the Activity Log records them, the numbers and Macs they may text from apply, and an archived conversation comes back to the Inbox. With send_at the message is scheduled instead, exactly like the Inbox's "Schedule message": the call answers 201 with the scheduled message, and at that time it is sent from the lead's usual number or sender as the key's user, with the same opt-out, credit and limit checks as a send now (a refusal then marks it failed with the reason). A contact who opted out, or has no phone or email for the channel, is refused up front (400). A scheduled message counts against the key's credit budget when it is scheduled. See, change, cancel or send it early with /v1/scheduled-messages.

ParameterDescription
lead_id
body · string
Lead to message. Omit when using to.
to
body · string
Raw destination: E.164 phone for sms, email address for email. Requires workspace_id.
channel
body · string · required
sms or email.
One of: sms, email
body
body · string · required
Message text.
subject
body · string
Required for email.
name
body · string
Contact name used when to creates a new lead.
media_urls
body · array
Up to 10 MMS/attachment URLs.
actor_label
body · string
Sender label shown in the timeline.
send_at
body · string
Schedule instead of sending now: ISO 8601 with a time zone (Z or an offset), e.g. 2026-10-20T09:00:00-05:00. In the future and at most 365 days ahead. Answers 201 with scheduled_message.
send_as
body · string
Texts, with an iMessage Mac connected (Beta): imessage sends a blue iMessage from one of the sub-account's iPhone numbers, sms from its business number, auto (default) lets the routing choose. The answer's transport says how it went (imessage, or mac_sms for the iPhone's own SMS).
One of: auto, imessage, sms
imessage_line_id
body · string
Texts: which Mac (iPhone number) sends it (GET /v1/imessage/lines).
audio_message
body · boolean
Texts: the one media_urls file is a voice message. Through a Mac running Advanced mode it arrives as a real iMessage audio message; anywhere else as an audio attachment. Not with send_at.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_id": "uuid",
    "channel": "sms",
    "body": "Hi Jane, are you still looking for a quote?"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_id: "uuid",
    channel: "sms",
    body: "Hi Jane, are you still looking for a quote?",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_id": "uuid",
        "channel": "sms",
        "body": "Hi Jane, are you still looking for a quote?",
    },
)
print(res.json())
#

List scheduled texts and emails

GET/v1/scheduled-messages
Read only

Messages waiting to be sent later, scheduled in the Inbox or with POST /v1/messages and send_at. By default the upcoming ones (pending, sending, failed), soonest first, like the dashed bubbles at the end of a conversation; status = sent, cancelled or all lists history, newest first. A failed message carries last_error. Viewers limited to their own leads only see those leads' messages.

ParameterDescription
lead_id
query · string
Only this lead's messages.
status
query · string
Default upcoming (pending, sending, failed).
One of: upcoming, pending, sending, failed, sent, cancelled, all
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/scheduled-messages?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/scheduled-messages?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/scheduled-messages?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "scheduled_messages": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "channel": "sms",
        "body": "Hi Jane, just checking in about your quote.",
        "subject": null,
        "from_number": null,
        "from_sender_id": null,
        "media_urls": [],
        "attachment_urls": [],
        "attachment_names": [],
        "scheduled_at": "2026-10-20T14:00:00+00:00",
        "status": "pending",
        "attempts": 0,
        "last_error": null,
        "sent_at": null,
        "lead_event_id": null,
        "created_by": "uuid",
        "cancelled_at": null,
        "cancelled_by": null,
        "created_at": "2026-10-16T18:02:11Z",
        "updated_at": "2026-10-16T18:02:11Z",
        "lead": {
          "id": "uuid",
          "full_name": "Jane Doe",
          "phone": "+14155550123",
          "email": null
        }
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

Change, cancel or restore a scheduled message

PATCH/v1/scheduled-messages/{scheduled_message_id}
Read and writeIdempotent

Like the Inbox's Edit, Cancel and Undo. Send any of body, subject (emails), scheduled_at and media_urls (texts) to change a pending or failed message: it goes back in the queue (status pending). status: "cancelled" (on its own) stops it; status: "pending" brings a cancelled one back (send a new scheduled_at when its time has passed). 409 when it is sending or already sent. Owners and admins only.

ParameterDescription
scheduled_message_id
path · string · required
Scheduled message id.
body
body · string
The new text.
subject
body · string
The new subject (emails only).
scheduled_at
body · string
ISO 8601 with a time zone, in the future and at most 365 days ahead.
media_urls
body · array
Picture URLs for a text, max 10 (replaces the list).
status
body · string
cancelled to cancel, pending to restore.
One of: pending, cancelled
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "scheduled_at": "2026-10-21T09:00:00-05:00",
    "body": "Hi Jane, following up on your quote. Any questions?"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    scheduled_at: "2026-10-21T09:00:00-05:00",
    body: "Hi Jane, following up on your quote. Any questions?",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "scheduled_at": "2026-10-21T09:00:00-05:00",
        "body": "Hi Jane, following up on your quote. Any questions?",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "scheduled_message": {
      "id": "uuid",
      "channel": "sms",
      "body": "Hi Jane, following up on your quote. Any questions?",
      "scheduled_at": "2026-10-21T14:00:00+00:00",
      "status": "pending"
    },
    "changed": true
  },
  "error": null
}
#

Send a scheduled message now

POST/v1/scheduled-messages/{scheduled_message_id}/send-now
Read and writeUses creditsIdempotent

The Inbox's "Send now": a pending or failed message goes out at once, as the teammate who scheduled it, with the usual opt-out, credit and limit checks, and is billed like any text or email. 200 = sent; 202 = a sending hiccup, it tries again by itself in a couple of minutes; 400 = refused, with the reason (the message is marked failed); 409 = cancelled, already sending or sent. Refused when the key's credit budget is used up. Owners and admins only.

ParameterDescription
scheduled_message_id
path · string · required
Scheduled message id.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID/send-now" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID/send-now", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/scheduled-messages/SCHEDULED_MESSAGE_ID/send-now",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "sent": true,
    "scheduled_message": {
      "id": "uuid",
      "status": "sent",
      "sent_at": "2026-10-16T18:05:02Z",
      "lead_event_id": "uuid"
    }
  },
  "error": null
}
#

List recent messages on this sub-account

GET/v1/messages
Read only

Every SMS and email on the sub-account's CRM numbers and inboxes, newest first. For polling, pass cursor (or since) — results then come oldest-first and next_cursor walks forward with no duplicates and no missed messages. Poll no more than once every 10 seconds, or subscribe to the message.received webhook instead. A text that went through one of the sub-account's iMessage Macs carries transport (imessage, or mac_sms for the iPhone's own green text) and imessage { line_id, from, service: imessage | sms | rcs, delivered_at, read_at }; a tapback carries reaction { by: them | you, type, emoji, reacts_to: the message it hangs on, removed }.

ParameterDescription
lead_id
query · string
Only messages with this contact.
channel
query · string
sms or email.
One of: sms, email
direction
query · string
inbound or outbound.
One of: inbound, outbound
since
query · string
ISO-8601 timestamp; returns messages created after it, oldest-first.
cursor
query · string
next_cursor from the previous poll.
limit
query · number
Default 50, max 200.
Request
cURL
curl "https://api.ottopros.com/v1/messages?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/messages?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Read one message

GET/v1/messages/{message_id}
Read only
ParameterDescription
message_id
path · string · required
Message id from GET /v1/messages.
Request
cURL
curl "https://api.ottopros.com/v1/messages/MESSAGE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages/MESSAGE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/messages/MESSAGE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Check today's contact-safety limits

GET/v1/messages/limits
Read only

Returns the daily unique-contact cap (default 300 per UTC day), how many contacts have been used, what's left, segment usage and the reset time. The same values ride on every messaging call as X-Contacts-Limit / -Used / -Remaining / -Reset headers.

Request
cURL
curl "https://api.ottopros.com/v1/messages/limits?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages/limits?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/messages/limits?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Read the SMS/email thread with a lead

GET/v1/leads/{lead_id}/conversation
Read only
ParameterDescription
lead_id
path · string · required
Lead id.
limit
query · number
Default 50.
Request
cURL
curl "https://api.ottopros.com/v1/leads/LEAD_ID/conversation" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/conversation", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/leads/LEAD_ID/conversation",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

List the sub-account's iMessage Macs

GET/v1/imessage/lines
Read only

Every Mac connected to the sub-account for iMessage, the default first: its iPhone number, whose Mac it is, whether it's online, what the Mac reports about itself (health: Messages signed in, Full Disk Access, awake, lid closed allowed, iCloud still syncing, the number missing from or switched off in Messages), whether Advanced mode runs on it (advanced_mode: real audio messages and reactions), its settings (scope, create_leads, new_conversations, fallback, offline_minutes, ai_replies), who can text from it (access, allowed_user_ids, and can_use for you), its limits (new conversations a day and an hour, tries and length before someone first replies) and today's usage (new conversations today and this hour, messages waiting, every text it sent today, and how many other accounts share that iPhone's limit). has_number says whether the sub-account has a business number to fall back on; without one every text goes through a Mac. Any member. Connecting and disconnecting a Mac happen in the app.

Request
cURL
curl "https://api.ottopros.com/v1/imessage/lines?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/imessage/lines?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/imessage/lines?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lines": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "number": "+17145550123",
        "handles": [
          "+17145550123"
        ],
        "label": null,
        "device_name": "Front desk iMac",
        "owner": {
          "user_id": "uuid",
          "name": "Pat Owner"
        },
        "is_default": true,
        "online": true,
        "last_seen_at": "2026-10-23T15:32:02Z",
        "connected_at": "2026-10-01T16:00:00Z",
        "app_version": "0.6.3",
        "macos_version": "15.6",
        "advanced_mode": false,
        "health": {
          "messages_running": true,
          "signed_in": true,
          "full_disk_access": true,
          "automation": true,
          "awake": true,
          "login_item": true,
          "laptop": false,
          "lid_closed_ok": null,
          "on_battery": false,
          "icloud_syncing": false,
          "has_number": true,
          "number_off": false,
          "advanced": false,
          "advanced_ready": false
        },
        "settings": {
          "scope": "crm",
          "create_leads": true,
          "new_conversations": "sms",
          "fallback": "mac_then_number",
          "offline_minutes": 10,
          "ai_replies": true
        },
        "access": "everyone",
        "allowed_user_ids": [],
        "can_use": true,
        "can_manage": true,
        "limits": {
          "new_per_day": 50,
          "new_per_hour": 6,
          "first_contact_messages": 5,
          "first_contact_chars": 300
        },
        "usage": {
          "new_today": 12,
          "new_this_hour": 2,
          "waiting": 0,
          "shared_with": 0,
          "messages_today": 41
        }
      }
    ],
    "has_number": true
  },
  "error": null
}
#

Change an iMessage Mac's settings

PATCH/v1/imessage/lines/{line_id}
Read and writeIdempotent

Send only what changes. The sub-account's owners and admins change any Mac; everyone else only their own; only owners and admins choose the default (is_default: true moves it from the old one). access: everyone, owner (only the person whose Mac it is) or selected (allowed_user_ids); owners, admins and the Mac's owner can always text from it, and AI replies and automations aren't limited.

ParameterDescription
line_id
path · string · required
The Mac (GET /v1/imessage/lines).
label
body · string
A name for it (max 80), or null.
is_default
body · boolean
true makes it the default Mac.
access
body · string
Who can text from it by hand.
One of: everyone, owner, selected
allowed_user_ids
body · array
With access selected: the team members who can.
scope
body · string
crm: only conversations with people already in the CRM sync; all: every one-to-one conversation.
One of: crm, all
create_leads
body · boolean
With scope all: someone new who texts in becomes a lead.
new_conversations
body · string
How a first text to someone goes.
One of: sms, imessage
fallback
body · string
When they aren't on iMessage or the Mac is offline: the business number, RCS or SMS from the iPhone through the Mac, the iPhone and then the business number (mac_then_number, the default), or nothing.
One of: number, mac, mac_then_number, none
offline_minutes
body · number
How long a message waits for an offline Mac before the fallback, 2-240.
ai_replies
body · boolean
The AI texter may answer iMessages.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/imessage/lines/LINE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "access": "selected",
    "allowed_user_ids": [
      "uuid"
    ],
    "new_conversations": "imessage"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/imessage/lines/LINE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    access: "selected",
    allowed_user_ids: ["uuid"],
    new_conversations: "imessage",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/imessage/lines/LINE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "access": "selected",
        "allowed_user_ids": ["uuid"],
        "new_conversations": "imessage",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "line": {
      "id": "uuid",
      "number": "+17145550123",
      "access": "selected",
      "allowed_user_ids": [
        "uuid"
      ],
      "settings": {
        "new_conversations": "imessage"
      }
    }
  },
  "error": null
}
#

React to an iMessage (tapback)

POST/v1/messages/{message_id}/reaction
Read and writeIdempotent

Puts a tapback (❤️ 👍 👎 😂 ‼️ ❓) on a text that went through one of the sub-account's Macs, like the Inbox's reaction picker; remove: true takes yours back. Only a Mac running Advanced mode can (advanced_mode on GET /v1/imessage/lines): otherwise 409 advanced_off. The reaction is queued (202) and shows on the message (its reaction, by you) once the Mac has sent it; if it can't, a note says why on the conversation. Anyone who can see the lead and text from that number. Free, like every text through a Mac.

ParameterDescription
message_id
path · string · required
The text to react to (an id from GET /v1/messages or the lead's conversation).
reaction
body · string · required
love, like, dislike, laugh, emphasize or question (or the emoji).
One of: love, like, dislike, laugh, emphasize, question
remove
body · boolean
true takes your reaction back.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/messages/MESSAGE_ID/reaction" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "reaction": "love"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/messages/MESSAGE_ID/reaction", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    reaction: "love",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/messages/MESSAGE_ID/reaction",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "reaction": "love",
    },
)
print(res.json())
Response 202
JSON
{
  "success": true,
  "data": {
    "queued": true,
    "job_id": "uuid",
    "message_id": "uuid",
    "reaction": "love",
    "remove": false,
    "line_id": "uuid"
  },
  "error": null
}

Bookings

#

List booking calendars

GET/v1/calendars
Read only

Every setting POST / PATCH /v1/calendars takes, read the way the booking page reads it: hours, length, buffers, hosts' mode, texts, pricing (with each option's and add-on's id, used by availability and bookings), design, duration_days (full-day jobs), schedule_group (shared schedule), fields and questions (the booking form), image_url, the payment settings, a summary (price_from_cents, price_to_cents, length_label such as "4-6 hr", has_options, takes_payment) and booking_path (/c/<slug>).

Request
cURL
curl "https://api.ottopros.com/v1/calendars?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calendars?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "calendars": [
      {
        "id": "uuid",
        "slug": "triple-t-interior-exterior",
        "name": "Triple T Interior + Exterior",
        "title": "Triple T Interior + Exterior",
        "duration_minutes": 240,
        "duration_days": null,
        "schedule_group": "Main crew",
        "assignment_mode": "unassigned",
        "slots_per_window": 1,
        "max_days_ahead": 120,
        "pricing": {
          "enabled": true,
          "show_prices": true,
          "price_cents": null,
          "price_note": null,
          "options_label": "Vehicle size",
          "options": [
            {
              "id": "opt-car",
              "name": "Car",
              "description": null,
              "price_cents": 31500,
              "duration_minutes": 240,
              "duration_days": null,
              "image_url": null
            },
            {
              "id": "opt-van",
              "name": "Van",
              "description": null,
              "price_cents": 53500,
              "duration_minutes": 360,
              "duration_days": null,
              "image_url": null
            }
          ],
          "addons_label": "Add-ons",
          "addons": [
            {
              "id": "add-pet",
              "name": "Pet hair",
              "description": null,
              "price_cents": 4000,
              "extra_minutes": 30,
              "image_url": null
            }
          ],
          "deposit": {
            "mode": "fixed",
            "amount_cents": 5000,
            "percent": 25
          }
        },
        "design": null,
        "questions": [
          {
            "key": "vehicle",
            "label": "Vehicle year / make / model",
            "field_type": "text",
            "options": [],
            "required": true,
            "enabled": true,
            "save_to_crm": true,
            "placeholder": null,
            "helper_text": null
          }
        ],
        "summary": {
          "price_from_cents": 31500,
          "price_to_cents": 53500,
          "length_label": "4-6 hr",
          "has_options": true,
          "takes_payment": true
        },
        "booking_path": "/c/triple-t-interior-exterior",
        "is_active": true
      }
    ]
  },
  "error": null
}
#

Get open appointment slots

GET/v1/calendars/{calendar_id}/availability
Read only

The times the calendar's booking page offers, worked out the same way: weekly hours and days off, the job length, buffers, capacity (slots_per_window jobs at one moment), calendars sharing its schedule, max per day, minimum notice, the booking window (max_days_ahead, up to a year) and the hosts' own calendars. A calendar with options times each one differently, so send option_id (400 lists the options otherwise) and any addon_ids; the answer carries a quote (option, add-ons, length, total, deposit, balance). Full-day calendars also return slot_ends (when each start's job is done). Always call this before creating a booking.

ParameterDescription
calendar_id
path · string · required
Calendar id.
from
query · string
A date (2026-11-03) or an ISO time; defaults to today.
days
query · number
1-42, default 7.
option_id
query · string
The option picked (its id in GET /v1/calendars pricing.options). Required on a calendar with options.
addon_ids
query · string
Add-on ids, comma-separated: they add their time to the job.
Request
cURL
curl "https://api.ottopros.com/v1/calendars/CALENDAR_ID/availability" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/calendars/CALENDAR_ID/availability", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/calendars/CALENDAR_ID/availability",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "calendar": {
      "id": "uuid",
      "name": "Triple T Interior + Exterior",
      "timezone": "America/Chicago",
      "duration_minutes": 390,
      "duration_days": null,
      "booking_window_days": 120
    },
    "from": "2026-11-03T06:00:00.000Z",
    "to": "2026-11-10T06:00:00.000Z",
    "from_date": "2026-11-03",
    "to_date": "2026-11-10",
    "slots": [
      "2026-11-03T14:00:00.000Z"
    ],
    "days": [
      {
        "date": "2026-11-03",
        "label": "Tue, Nov 3",
        "slots": [
          "2026-11-03T14:00:00.000Z"
        ]
      }
    ],
    "quote": {
      "option": {
        "id": "opt-van",
        "name": "Van"
      },
      "addons": [
        {
          "id": "add-pet",
          "name": "Pet hair"
        }
      ],
      "duration_minutes": 390,
      "duration_days": null,
      "length_label": "6 hr 30 min",
      "total_cents": 57500,
      "deposit_cents": 5000,
      "balance_cents": 52500,
      "currency": "usd",
      "lines": [
        {
          "label": "Van",
          "cents": 53500,
          "kind": "option"
        },
        {
          "label": "Pet hair",
          "cents": 4000,
          "kind": "addon"
        }
      ]
    }
  },
  "error": null
}
#

List appointments

GET/v1/bookings
Read only

Soonest first. Each appointment has the same fields as everywhere else in the API: id, lead_id, calendar_id, appointment_number (the sub-account's #1001 and up, as the calendar shows it), title, starts_at, ends_at, timezone, lead_timezone, location, notes, meeting_type, meeting_location, meeting_url, host_user_id, status, source, notify_lead, is_arrival_window (booked as an arrival window: starts_at to ends_at is the window), service_details (on a priced calendar: the service, option, add-ons, length, total_cents, deposit_cents, balance_cents and currency; null otherwise), created_at and updated_at.

ParameterDescription
from
query · string
ISO start.
to
query · string
ISO end.
status
query · string
Filter by status.
lead_id
query · string
Filter by lead.
Request
cURL
curl "https://api.ottopros.com/v1/bookings?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/bookings?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "bookings": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "calendar_id": "uuid",
        "title": "Estimate",
        "starts_at": "2026-09-04T17:00:00Z",
        "ends_at": "2026-09-04T19:00:00Z",
        "timezone": "America/Chicago",
        "lead_timezone": "America/Chicago",
        "location": "12 Main St",
        "notes": null,
        "meeting_type": "in_person",
        "meeting_location": null,
        "meeting_url": null,
        "host_user_id": "uuid",
        "status": "scheduled",
        "source": "api",
        "notify_lead": true,
        "is_arrival_window": true,
        "service_details": null,
        "created_at": "2026-09-01T15:00:00Z",
        "updated_at": "2026-09-01T15:00:00Z"
      }
    ]
  },
  "error": null
}
#

Book an appointment

POST/v1/bookings
Read and writeIdempotent

Books like the app: rejects a second overlapping appointment for the same lead; calendar_id must be one of the sub-account's calendars (404 otherwise); ends_at must be after starts_at and at most 24 hours later. With a calendar, the slot's capacity is checked, calendars sharing its schedule included (409 with code slot_full; pick a time from GET /v1/calendars/{calendar_id}/availability, or send force: true) and the host is picked by the calendar's round robin. On a priced calendar, option_id and addon_ids set the job's length and price: the booking's notes start with the pick and its service_details carry the option, add-ons, total, deposit and balance; the lead's deal value is filled when it has none (the response adds a quote; a warning says when a calendar with options was booked without one). Bookings made through the API never take the deposit: to collect it, send the customer the booking link. A full-day calendar's job starts at opening time and ends at closing time on its last working day (400 not_a_start_time otherwise, unless ends_at is sent). By default the lead gets the confirmation and reminder texts and the calendar invite (notify_lead: false to skip them). Fires the appointment.booked outbound webhooks.

ParameterDescription
starts_at
body · string · required
ISO 8601 start time.
ends_at
body · string
ISO 8601 end time (instead of duration_minutes; at most 24 h after starts_at, 31 days on a full-day calendar).
lead_id
body · string
Lead to book.
calendar_id
body · string
One of the sub-account's calendars.
option_id
body · string
The option booked (GET /v1/calendars pricing.options id), e.g. the vehicle size.
addon_ids
body · array
Add-ons booked (pricing.addons ids).
duration_minutes
body · number
5-1440. Default: the option's length with its add-ons, else the calendar's, else 30.
timezone
body · string
IANA timezone.
location
body · string
Meeting location.
notes
body · string
Internal notes.
notify_lead
body · boolean
Default true: the lead gets the confirmation email with its calendar invite plus the confirmation and reminder texts. false books quietly, and later updates or cancels stay quiet too; the host still gets the booking alert.
force
body · boolean
Skip the calendar's capacity check (the database still never double-books a person or a full calendar).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/bookings" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_id": "uuid",
    "calendar_id": "uuid",
    "starts_at": "2026-11-03T14:00:00Z",
    "option_id": "opt-van",
    "addon_ids": [
      "add-pet"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_id: "uuid",
    calendar_id: "uuid",
    starts_at: "2026-11-03T14:00:00Z",
    option_id: "opt-van",
    addon_ids: ["add-pet"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/bookings",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_id": "uuid",
        "calendar_id": "uuid",
        "starts_at": "2026-11-03T14:00:00Z",
        "option_id": "opt-van",
        "addon_ids": ["add-pet"],
    },
)
print(res.json())
#

Move an appointment

POST/v1/bookings/{booking_id}/reschedule
Read and writeIdempotent

Re-times the reminder texts, updates the calendar mirror and the lead's invite, and fires the appointment.rescheduled outbound webhooks when the time changed. The appointment keeps its length; a full-day job runs to closing time on its new last working day. 409 slot_full when the new time is taken.

ParameterDescription
booking_id
path · string · required
Booking id.
starts_at
body · string · required
New ISO start time.
duration_minutes
body · number
Optional new length, 5-1440.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/bookings/BOOKING_ID/reschedule" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings/BOOKING_ID/reschedule", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/bookings/BOOKING_ID/reschedule",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Cancel an appointment

POST/v1/bookings/{booking_id}/cancel
Read and writeIdempotent

DELETE /v1/bookings/{booking_id} does the same.

ParameterDescription
booking_id
path · string · required
Booking id.
reason
body · string
Cancellation reason.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/bookings/BOOKING_ID/cancel" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings/BOOKING_ID/cancel", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/bookings/BOOKING_ID/cancel",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Change an appointment's outcome, title, notes, location, host, lead notifications or lead

PATCH/v1/bookings/{booking_id}
Read and writeIdempotent

Everything but the time (use reschedule for that). status records the outcome: completed, no_show or cancelled release the lead from Booked and stop its reminder texts; confirmed puts it back on. host_user_id must be someone on the sub-account's team (GET /v1/members). notify_lead: false silences the lead's remaining reminder texts and invite updates; true turns them back on (an upcoming appointment's reminders are queued again). lead_id links an appointment that has no lead yet (typed in by hand) to a lead of the same sub-account; one that already has a lead answers 409. Linking an upcoming scheduled appointment books it for the lead like a new booking: the lead is marked booked, its follow-ups and AI calls stop, and the confirmation and reminder texts and the invite go out (unless notify_lead is false). A past or finished appointment is only linked.

ParameterDescription
booking_id
path · string · required
Booking id.
status
body · string
The appointment's outcome.
One of: confirmed, completed, no_show, cancelled
title
body · string
Max 200.
notes
body · string
Max 2000. null clears them.
location
body · string
Address or place, max 300. null clears it.
host_user_id
body · string
Team member who takes it. null for nobody.
notify_lead
body · boolean
false: the lead gets no more reminder texts or invite updates for this appointment. true: they do again.
lead_id
body · string
Link an appointment with no lead to this lead (same sub-account). Cannot move an appointment from one lead to another.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/bookings/BOOKING_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "status": "completed",
    "notes": "Signed the quote on site."
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/bookings/BOOKING_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    status: "completed",
    notes: "Signed the quote on site.",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/bookings/BOOKING_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "status": "completed",
        "notes": "Signed the quote on site.",
    },
)
print(res.json())

Members

#

List sub-account members and invites

GET/v1/members
Read only

Each member has user_id, role, email and full_name. Use user_id (or the email) to name a team member in busy blocks. Staff and Technicians, who have no Team page in the app, get names and roles only (no emails, no invitations).

Request
cURL
curl "https://api.ottopros.com/v1/members?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/members?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/members?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Invite a member

POST/v1/members
Full accessIdempotent
ParameterDescription
email
body · string · required
Invitee email.
role
body · string
client_admin (Admin), client_viewer (Staff) or client_tech (Technician: only their own leads and appointments). Default client_viewer.
One of: client_admin, client_viewer, client_tech
Request
cURL
curl -X POST "https://api.ottopros.com/v1/members" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/members", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/members",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Change a member's role

PATCH/v1/members/{member_id}
Full accessIdempotent

You cannot change your own role.

ParameterDescription
member_id
path · string · required
Member id.
role
body · string · required
client_admin (Admin), client_viewer (Staff) or client_tech (Technician: only their own leads and appointments).
One of: client_admin, client_viewer, client_tech
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/members/MEMBER_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/members/MEMBER_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/members/MEMBER_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Remove a member

DELETE/v1/members/{member_id}
Full accessIdempotent
ParameterDescription
member_id
path · string · required
Member id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/members/MEMBER_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/members/MEMBER_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/members/MEMBER_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())

Smart forms

#

List smart forms

GET/v1/smart-forms
Read only
Request
cURL
curl "https://api.ottopros.com/v1/smart-forms?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/smart-forms?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/smart-forms?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

List form submissions

GET/v1/smart-forms/{form_id}/submissions
Read only
ParameterDescription
form_id
path · string · required
Form id.
limit
query · number
Default 50.
Request
cURL
curl "https://api.ottopros.com/v1/smart-forms/FORM_ID/submissions" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/smart-forms/FORM_ID/submissions", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/smart-forms/FORM_ID/submissions",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Create a smart form

POST/v1/smart-forms
Read and writeIdempotent

Starts unpublished with the same defaults as a form created in the app. The response includes its public path (/f/<slug>), qualifier_questions, custom_fields, qualification, calendar_routing, service_area and estimate. Questions can ask for photos (field_type file) and put a price on answers (option_prices) for the instant estimate: switched on with estimate, the answers' prices add up to a live total in the form's footer, saved on the lead with its total as the deal value. Qualification routes each lead to Qualified, Nurture or Not eligible: yes/no qualifier_questions (asked first when qualifier_enabled is on) and choice questions with disqualify_options or nurture_options. A service area (fields.address.service_area) only accepts addresses in chosen US states or within a radius: address suggestions stay inside it and an out-of-area visitor sees your message and cannot send the form; switching it on shows and requires the address, like the builder. Design and tracking pixels are edited in the app.

ParameterDescription
name
body · string · required
Form name (max 80).
headline
body · string
Max 160.
subheadline
body · string
Max 300.
primary_color
body · string
#rrggbb.
bullet_points
body · array
Up to 8 short lines.
layout_mode
body · string
The builder's Layout: single_step (one page, the default), multi_step (a step-by-step wizard), conversational (one question at a time, like a chat) or funnel (one big question per screen).
One of: single_step, multi_step, conversational, funnel
calendar_id
body · string
Calendar used by the booking step.
calendar_routing
body · boolean
Pick the calendar from the answers: the sub-account's calendar routing rules (/v1/calendar-routes) choose which calendar's times the form shows once the deciding question is answered; calendar_id is used when no rule fits.
booking_enabled
body · boolean
Show the booking step.
fields
body · object
Contact fields: {"email":{"required":false},"address":{"enabled":false}}. Keys: first_name, last_name, phone, email, address, state (a State / Province pick list), notes. Phone or email must stay on. address also takes service_area (merged): {"enabled":true,"mode":"states","states":["TX","OK"],"message":"Sorry, we only serve Texas and Oklahoma."}, or mode "radius" with "center":{"lat":32.7767,"lng":-96.797,"label":"Dallas, TX"} and "radius_miles" (1-500, default 25). A blank message shows "Sorry, we don't service this area yet."; null or enabled:false switches it off.
is_published
body · boolean
Default false.
qualifier_enabled
body · boolean
Ask the qualifier_questions first.
qualifier_questions
body · array
Up to 10, replaces the list: [{ text (max 200), disqualify_on: "yes" | "no" (default "no"), outcome: "not_eligible" | "nurture" (default "not_eligible") }].
questions
body · array
Custom questions, up to 30, replaces the list: [{ key?, label (max 160), field_type: text | textarea | number | money | phone | email | url | select | multiselect | boolean | date | datetime | rating | file (photos or PDFs, up to 6, shown on the lead), options (select / multiselect: 2-40), option_prices? (select / multiselect: cents per answer for the instant estimate, e.g. {"SUV": 8000}; left out keeps the saved prices, null removes them), disqualify_options? or nurture_options? (choice types; boolean answers "yes" / "no"; never both), placement?: "qualify" | "details" (qualify = choice types only), option_style?: dropdown | buttons | cards | auto, no_first? (boolean: show No before Yes), required?, enabled?, save_to_crm? (never for file), placeholder?, helper_text? }].
qualification
body · object
Merged: { save_not_eligible: boolean, nurture_page: { headline, subheadline, cta_label, cta_url, redirect_url } }.
estimate
body · object
The instant price estimate, merged: { enabled, label (default "Your estimate"), base_cents (added before any answer), note (e.g. "Final price after we see the job"), range_percent (0-50: shown as a range that far either side) }. null switches it off.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/smart-forms" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Roof quote",
    "qualifier_enabled": true,
    "qualifier_questions": [
      {
        "text": "Do you own the home?",
        "disqualify_on": "no",
        "outcome": "not_eligible"
      }
    ],
    "questions": [
      {
        "label": "When do you want to start?",
        "field_type": "select",
        "options": [
          "This month",
          "In 1-3 months",
          "Just researching"
        ],
        "nurture_options": [
          "Just researching"
        ],
        "placement": "qualify"
      },
      {
        "label": "Vehicle size",
        "field_type": "select",
        "options": [
          "Car",
          "SUV",
          "Truck"
        ],
        "option_prices": {
          "Car": 31500,
          "SUV": 38500,
          "Truck": 45000
        }
      },
      {
        "label": "Photos of the damage",
        "field_type": "file"
      }
    ],
    "qualification": {
      "save_not_eligible": true
    },
    "estimate": {
      "enabled": true,
      "note": "Final price after we see the job",
      "range_percent": 10
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/smart-forms", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Roof quote",
    qualifier_enabled: true,
    qualifier_questions: [
      {
        text: "Do you own the home?",
        disqualify_on: "no",
        outcome: "not_eligible",
      },
    ],
    questions: [
      {
        label: "When do you want to start?",
        field_type: "select",
        options: ["This month", "In 1-3 months", "Just researching"],
        nurture_options: ["Just researching"],
        placement: "qualify",
      },
      {
        label: "Vehicle size",
        field_type: "select",
        options: ["Car", "SUV", "Truck"],
        option_prices: {
          Car: 31500,
          SUV: 38500,
          Truck: 45000,
        },
      },
      {
        label: "Photos of the damage",
        field_type: "file",
      },
    ],
    qualification: {
      save_not_eligible: true,
    },
    estimate: {
      enabled: true,
      note: "Final price after we see the job",
      range_percent: 10,
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/smart-forms",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Roof quote",
        "qualifier_enabled": True,
        "qualifier_questions": [
            {
                "text": "Do you own the home?",
                "disqualify_on": "no",
                "outcome": "not_eligible",
            },
        ],
        "questions": [
            {
                "label": "When do you want to start?",
                "field_type": "select",
                "options": ["This month", "In 1-3 months", "Just researching"],
                "nurture_options": ["Just researching"],
                "placement": "qualify",
            },
            {
                "label": "Vehicle size",
                "field_type": "select",
                "options": ["Car", "SUV", "Truck"],
                "option_prices": {
                    "Car": 31500,
                    "SUV": 38500,
                    "Truck": 45000,
                },
            },
            {
                "label": "Photos of the damage",
                "field_type": "file",
            },
        ],
        "qualification": {
            "save_not_eligible": True,
        },
        "estimate": {
            "enabled": True,
            "note": "Final price after we see the job",
            "range_percent": 10,
        },
    },
)
print(res.json())
#

Update or publish a smart form

PATCH/v1/smart-forms/{form_id}
Read and writeIdempotent

Takes the same fields as create; send only what changes. questions and qualifier_questions replace their lists; qualification, estimate, fields and fields.address.service_area are merged.

ParameterDescription
form_id
path · string · required
Form id.
is_published
body · boolean
Publish or unpublish.
layout_mode
body · string
single_step, multi_step, conversational or funnel (see create).
One of: single_step, multi_step, conversational, funnel
qualifier_enabled
body · boolean
Ask the qualifier_questions first.
qualifier_questions
body · array
Replaces the list (see create).
questions
body · array
Replaces the list (see create).
qualification
body · object
Merged (see create).
estimate
body · object
Merged (see create).
calendar_routing
body · boolean
Pick the calendar from the answers (see create).
fields
body · object
Merged, including address.service_area (see create).
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/smart-forms/FORM_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "fields": {
      "address": {
        "service_area": {
          "enabled": true,
          "mode": "radius",
          "center": {
            "lat": 32.7767,
            "lng": -96.797,
            "label": "Dallas, TX"
          },
          "radius_miles": 50
        }
      }
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/smart-forms/FORM_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    fields: {
      address: {
        service_area: {
          enabled: true,
          mode: "radius",
          center: {
            lat: 32.7767,
            lng: -96.797,
            label: "Dallas, TX",
          },
          radius_miles: 50,
        },
      },
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/smart-forms/FORM_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "fields": {
            "address": {
                "service_area": {
                    "enabled": True,
                    "mode": "radius",
                    "center": {
                        "lat": 32.7767,
                        "lng": -96.797,
                        "label": "Dallas, TX",
                    },
                    "radius_miles": 50,
                },
            },
        },
    },
)
print(res.json())

CRM automations

#

List sub-account automations

GET/v1/workspace-automations
Read only
Request
cURL
curl "https://api.ottopros.com/v1/workspace-automations?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspace-automations?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Enroll leads into an automation

POST/v1/workspace-automations/{automation_id}/enroll
Read and writeIdempotent

Can send real texts and emails. Confirm the lead list before calling. Leads are queued and start within the sub-account's daily contact limit. A lead already waiting in this automation's queue is not queued again (already_queued). ignore_filters defaults to false: a lead that fails the automation's only_if rules is skipped when the queue reaches it (the app's Enroll dialog defaults to true).

ParameterDescription
automation_id
path · string · required
Automation id.
lead_ids
body · array · required
Lead ids (max 5000).
ignore_filters
body · boolean
Default false: leads that fail the automation's only_if rules are skipped. true = enroll them anyway.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enroll" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_ids": [
      "uuid"
    ],
    "ignore_filters": false
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enroll", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_ids: ["uuid"],
    ignore_filters: false,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enroll",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_ids": ["uuid"],
        "ignore_filters": False,
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "queued": 1,
    "skipped": 0,
    "already_queued": 0,
    "automation": {
      "id": "uuid",
      "name": "Speed to lead"
    },
    "batch_id": "uuid"
  },
  "error": null
}
#

Enable or disable a sub-account automation

POST/v1/workspace-automations/{automation_id}/toggle
Read and writeIdempotent
ParameterDescription
automation_id
path · string · required
Automation id.
enabled
body · boolean
Omit to flip the current state.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/toggle" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/toggle", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/toggle",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Every trigger, step, filter and setting the builder accepts

GET/v1/workspace-automations/reference
Read only

Read this before building: trigger types and their filters, step actions and their fields, entry-rule fields and operators (with every lead source), settings, message variables, and what this sub-account has for a definition to point at: stages, team, custom fields, phone numbers, review funnel, Facebook pages, Smart Forms, form names (for form_name rules), calendars and email senders.

Request
cURL
curl "https://api.ottopros.com/v1/workspace-automations/reference?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/reference?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspace-automations/reference?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Build an automation

POST/v1/workspace-automations
Full accessIdempotent

Saved exactly as the Automations page saves it, so it opens and edits in the app. Saved OFF unless enabled is true; once on, its texts and emails go to real leads through the normal quiet hours, opt-out and daily limits. Steps can send lead data out (webhooks, alerts), so this needs an admin-scoped key. Everything it points at (stages, team members, phone numbers, review pages, Facebook pages, Smart Forms, calendars, email senders) must be in the same sub-account; GET /v1/workspace-automations/reference lists them. Any step can take a label (its name on the step card). An inbound_webhook automation comes back with its private inbound_url: POST JSON with a phone or email to start it (it must be on), and every field sent can be used in its messages as {field}.

ParameterDescription
name
body · string · required
Up to 80 characters.
description
body · string
Optional note.
trigger
body · object · required
When it starts: {"type":"new_lead"}, {"type":"stage_changed","to_stages":["booked"]}, {"type":"tag_added","tags":["hot"]}, {"type":"smart_form_submitted","form_ids":["Free quote"]}, {"type":"appointment_booked","calendar_ids":["Estimate visit"]}, {"type":"inbound_webhook"}... Ids or names. See GET /v1/workspace-automations/reference.
only_if
body · array
Entry rules, all must hold: [{"field":"tags","op":"has_none","value":["customer"]}].
steps
body · array · required
In order: [{"action":"send_text","message":"Hi {first_name}"},{"action":"wait","days":1},{"action":"send_email","subject":"Your quote","message":"..","from_sender_id":"<email sender id, address or name>"},{"action":"if","conditions":[..],"then":[..],"else":[..]}]. A send_text step takes send_as (auto, imessage or sms) when the sub-account has an iMessage Mac.
settings
body · object
{"stop_on_reply":true,"sending_window":{"days":["mon","tue"],"start":"09:00","end":"18:00"}}.
enabled
body · boolean
Default false.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspace-automations" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Speed to lead",
    "trigger": {
      "type": "new_lead"
    },
    "steps": [
      {
        "action": "send_text",
        "message": "Hi {first_name}, thanks for reaching out! When is a good time to talk?"
      },
      {
        "action": "wait",
        "days": 1
      },
      {
        "action": "send_text",
        "message": "Still interested, {first_name}?"
      }
    ],
    "settings": {
      "stop_on_reply": true
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "Speed to lead",
    trigger: {
      type: "new_lead",
    },
    steps: [
      {
        action: "send_text",
        message: "Hi {first_name}, thanks for reaching out! When is a good time to talk?",
      },
      {
        action: "wait",
        days: 1,
      },
      {
        action: "send_text",
        message: "Still interested, {first_name}?",
      },
    ],
    settings: {
      stop_on_reply: true,
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspace-automations",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Speed to lead",
        "trigger": {
            "type": "new_lead",
        },
        "steps": [
            {
                "action": "send_text",
                "message": "Hi {first_name}, thanks for reaching out! When is a good time to talk?",
            },
            {
                "action": "wait",
                "days": 1,
            },
            {
                "action": "send_text",
                "message": "Still interested, {first_name}?",
            },
        ],
        "settings": {
            "stop_on_reply": True,
        },
    },
)
print(res.json())
#

One automation as {trigger, only_if, steps, settings}

GET/v1/workspace-automations/{automation_id}
Read only

The same shape create and update take (step names included), with a ref on every step so an update can keep steps unchanged ({"keep":"<ref>"}). Webhook header values are hidden. An inbound_webhook automation includes its inbound_url; viewers see only its host, like webhook step links.

ParameterDescription
automation_id
path · string · required
Automation id.
Request
cURL
curl "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Change an automation

PATCH/v1/workspace-automations/{automation_id}
Full accessIdempotent

Send only what changes. steps replaces all steps: keep unchanged ones with {"keep":"<ref>"} from GET. settings changes only the keys sent. enabled turns it on or off.

ParameterDescription
automation_id
path · string · required
Automation id.
name
body · string
New name.
trigger
body · object
New trigger.
only_if
body · array
New entry rules.
steps
body · array
New steps (replaces all).
settings
body · object
Settings to change.
enabled
body · boolean
On or off.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Copy an automation

POST/v1/workspace-automations/{automation_id}/duplicate
Full accessIdempotent

The copy is saved OFF unless enabled is true.

ParameterDescription
automation_id
path · string · required
Automation id.
name
body · string
The copy's name.
enabled
body · boolean
Default false.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/duplicate" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/duplicate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/duplicate",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Delete an automation

DELETE/v1/workspace-automations/{automation_id}
Full accessIdempotent

Leads inside it stop where they are.

ParameterDescription
automation_id
path · string · required
Automation id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Who is inside an automation right now

GET/v1/workspace-automations/{automation_id}/enrollments
Read only

Leads waiting on a step or mid-step, newest first, with the time their next step runs, plus how many are still waiting to be enrolled.

ParameterDescription
automation_id
path · string · required
Automation id.
limit
query · number
1-100, default 100.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enrollments" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enrollments", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/enrollments",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "automation": {
      "id": "uuid",
      "name": "Speed to lead",
      "steps_total": 3
    },
    "enrollments": [
      {
        "run_id": "uuid",
        "lead": {
          "id": "uuid",
          "full_name": "Jane Doe",
          "phone": "+14155550123"
        },
        "status": "waiting",
        "step_index": 1,
        "started_at": "2026-09-30T14:00:00Z",
        "next_step_at": "2026-10-01T14:00:00Z"
      }
    ],
    "waiting_to_enroll": 0,
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

Take leads out of an automation

POST/v1/workspace-automations/{automation_id}/unenroll
Read and writeIdempotent

Like Remove from workflow in the app: their run stops, queued steps (texts, emails) are cancelled and the lead's timeline notes it. Nothing is sent.

ParameterDescription
automation_id
path · string · required
Automation id.
lead_ids
body · array
Up to 500 lead ids.
all
body · boolean
true = everyone in it (instead of lead_ids).
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/unenroll" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_ids": [
      "uuid"
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/unenroll", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_ids: ["uuid"],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspace-automations/AUTOMATION_ID/unenroll",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_ids": ["uuid"],
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "automation_id": "uuid",
    "removed": 1,
    "waiting_cancelled": 0
  },
  "error": null
}
#

The automations a lead is inside

GET/v1/leads/{lead_id}/workflows
Read only
ParameterDescription
lead_id
path · string · required
Lead id.
Request
cURL
curl "https://api.ottopros.com/v1/leads/LEAD_ID/workflows" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/leads/LEAD_ID/workflows", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/leads/LEAD_ID/workflows",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "lead_id": "uuid",
    "workflows": [
      {
        "run_id": "uuid",
        "automation_id": "uuid",
        "name": "Speed to lead",
        "status": "waiting",
        "step_index": 1,
        "started_at": "2026-09-30T14:00:00Z",
        "next_step_at": "2026-10-01T14:00:00Z"
      }
    ]
  },
  "error": null
}

Invoices & reviews

#

Invoices (read only)

GET/v1/invoices
Read only

The sub-account's invoices, newest first. effective_status adds overdue (sent and past due), as the app shows it. kind is invoice, deposit (it lists the whole job but asks only for deposit_cents now; deposit_percent when it was a percent) or balance (made by Bill the balance: deposit_invoice_id is the deposit it follows and deposit_applied_cents comes off the job total). total_cents is always what this invoice asks for. Owners and admins only. Payment processor details are never returned.

ParameterDescription
status
query · string
Filter.
One of: draft, sent, paid, void, overdue
lead_id
query · string
One lead's invoices.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/invoices?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/invoices?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/invoices?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "invoices": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "number": "INV-000042",
        "status": "sent",
        "currency": "usd",
        "issue_date": "2026-09-20",
        "due_date": "2026-09-27",
        "customer_name": "Jane Doe",
        "customer_email": "jane@example.com",
        "customer_phone": "+14155550123",
        "total_cents": 125000,
        "amount_paid_cents": 0,
        "collect_payment": true,
        "payment_gateway": "stripe",
        "sent_at": "2026-09-20T15:00:00Z",
        "viewed_at": null,
        "paid_at": null,
        "created_at": "2026-09-20T14:55:00Z",
        "updated_at": "2026-09-20T15:00:00Z",
        "effective_status": "overdue",
        "balance_cents": 125000
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

One invoice with its line items and history

GET/v1/invoices/{invoice_id}
Read only

Adds the customer's address, notes, terms, subtotal, discount and tax, the PDF link, line items, the event history (created, sent, viewed, paid) and public_path, the customer's view-and-pay page on the sub-account's domain (none for drafts).

ParameterDescription
invoice_id
path · string · required
Invoice id.
Request
cURL
curl "https://api.ottopros.com/v1/invoices/INVOICE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/invoices/INVOICE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/invoices/INVOICE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Estimates (read only)

GET/v1/estimates
Read only

The sub-account's estimates, newest first, as the Estimates tab lists them: number, title, the customer, status and effective_status (expired: sent and past its last day), when it was sent, viewed, approved (and by whom) or declined (and why), the invoice it became, its Good / Better / Best options with their totals, and total_cents (the option the customer picked, else the recommended one). Technicians and Staff limited to their own leads see the estimates for leads they can see and ones they made without a lead, as in the app.

ParameterDescription
status
query · string
Filter.
One of: draft, sent, approved, declined, expired, converted
lead_id
query · string
One lead's estimates.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/estimates?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/estimates?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "estimates": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "lead_id": "uuid",
        "number": "EST-001042",
        "title": "Duct cleaning, whole house",
        "status": "sent",
        "currency": "usd",
        "issue_date": "2026-10-20",
        "expires_at": "2026-11-19",
        "customer_name": "Jane Doe",
        "customer_email": "jane@example.com",
        "customer_phone": "+14155550123",
        "selected_option_id": null,
        "sent_at": "2026-10-20T16:00:00Z",
        "viewed_at": "2026-10-20T18:12:00Z",
        "approved_at": null,
        "approved_by_name": null,
        "declined_at": null,
        "decline_reason": null,
        "converted_invoice_id": null,
        "created_by": "uuid",
        "created_at": "2026-10-20T15:40:00Z",
        "updated_at": "2026-10-20T18:12:00Z",
        "effective_status": "sent",
        "total_cents": 64900,
        "options": [
          {
            "id": "uuid",
            "name": "Good",
            "is_recommended": false,
            "total_cents": 39900
          },
          {
            "id": "uuid",
            "name": "Better",
            "is_recommended": true,
            "total_cents": 64900
          }
        ]
      }
    ],
    "has_more": false,
    "next_cursor": null
  },
  "error": null
}
#

One estimate with its options, lines and history

GET/v1/estimates/{estimate_id}
Read only

Adds the customer's address, intro, terms, tax rate and the customer's approval note, every option (name, description, photo, recommended, subtotal, tax and total) with its lines (name, details, photo, quantity, unit price, taxable), the history (created, sent, viewed, approved, declined) and public_path, the customer's page on the sub-account's domain (none for drafts).

ParameterDescription
estimate_id
path · string · required
Estimate id.
Request
cURL
curl "https://api.ottopros.com/v1/estimates/ESTIMATE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates/ESTIMATE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/estimates/ESTIMATE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

The price book

GET/v1/price-book
Read only

The saved items estimates and invoices pick their lines from: name, details, category, unit price, taxable, photo, and how often it's used (most used first).

ParameterDescription
search
query · string
Name or category contains (2-80 characters).
include_inactive
query · boolean
Include archived items.
limit
query · number
1-500, default 100.
Request
cURL
curl "https://api.ottopros.com/v1/price-book?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/price-book?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/price-book?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "uuid",
        "workspace_id": "uuid",
        "name": "Dryer vent cleaning",
        "description": "Brush and vacuum the full run, check the exterior flap.",
        "category": "Vents",
        "unit_price_cents": 14900,
        "taxable": true,
        "image_url": null,
        "is_active": true,
        "usage_count": 37,
        "last_used_at": "2026-10-22T14:00:00Z",
        "created_at": "2026-09-01T00:00:00Z",
        "updated_at": "2026-10-22T14:00:00Z"
      }
    ]
  },
  "error": null
}
#

Create an estimate (draft)

POST/v1/estimates
Read and writeIdempotent

Saved as a draft exactly as the estimate editor saves one: the sub-account's next number, its default terms, days valid and tax rate, the lead's name, email, phone and address filled in. Send items for one option, or options (2-4, e.g. Good / Better / Best) each with its items; every option's subtotal, tax and total are worked out as the editor does (each line rounded, tax on taxable lines). Answers with the estimate as GET /v1/estimates/{estimate_id} shows it. Send it with POST /v1/estimates/{estimate_id}/send. Technicians only while the sub-account lets them make estimates, and only for their own leads.

ParameterDescription
lead_id
body · string
Who it is for (fills in the customer; needed to send it). Or customer_name.
title
body · string
Max 200, e.g. Whole-house duct cleaning.
intro
body · string
A note to the customer at the top (max 2000).
items
body · array
One option's lines: [{ description (the line's name, max 300), details? (max 2000), image_url? (https), quantity? (default 1), unit_price_cents, taxable? (default true) }].
options
body · array
Instead of items, up to 4 choices: [{ name, description?, image_url?, recommended?, items: [...] }]. One is recommended (the first marked, else the first).
tax_rate
body · number
Percent, 0-30. Default: the sub-account's.
valid_days
body · number
Days it is valid (1-365). Default: the sub-account's. Or expires_at.
expires_at
body · string
Last day, YYYY-MM-DD, or null for no end.
terms
body · string
Max 4000. Default: the sub-account's estimate terms.
currency
body · string
3 letters, default usd.
customer_name
body · string
Without a lead, or to change what the lead fills in.
customer_email
body · string
Customer email.
customer_phone
body · string
Customer phone.
customer_address
body · string
Customer address.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/estimates" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "lead_id": "uuid",
    "title": "Duct cleaning",
    "options": [
      {
        "name": "Good",
        "items": [
          {
            "description": "Main trunk lines",
            "unit_price_cents": 29900
          }
        ]
      },
      {
        "name": "Better",
        "recommended": true,
        "items": [
          {
            "description": "Every vent and return",
            "details": "Up to 20 vents, before and after photos.",
            "unit_price_cents": 49900
          }
        ]
      }
    ]
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    lead_id: "uuid",
    title: "Duct cleaning",
    options: [
      {
        name: "Good",
        items: [
          {
            description: "Main trunk lines",
            unit_price_cents: 29900,
          },
        ],
      },
      {
        name: "Better",
        recommended: true,
        items: [
          {
            description: "Every vent and return",
            details: "Up to 20 vents, before and after photos.",
            unit_price_cents: 49900,
          },
        ],
      },
    ],
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/estimates",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "lead_id": "uuid",
        "title": "Duct cleaning",
        "options": [
            {
                "name": "Good",
                "items": [
                    {
                        "description": "Main trunk lines",
                        "unit_price_cents": 29900,
                    },
                ],
            },
            {
                "name": "Better",
                "recommended": True,
                "items": [
                    {
                        "description": "Every vent and return",
                        "details": "Up to 20 vents, before and after photos.",
                        "unit_price_cents": 49900,
                    },
                ],
            },
        ],
    },
)
print(res.json())
#

Change an estimate

PATCH/v1/estimates/{estimate_id}
Read and writeIdempotent

Send only what changes. options (or items) replace every option and line, as the editor saves; a new tax_rate re-totals the options. An approved estimate or one already made into an invoice answers 409: make a new one.

ParameterDescription
estimate_id
path · string · required
Estimate id.
title
body · string
New title.
intro
body · string
New note at the top.
lead_id
body · string
Or null.
items
body · array
Replaces the options with one.
options
body · array
Replaces the options.
tax_rate
body · number
Percent, 0-30; re-totals the options.
valid_days
body · number
Days valid from today (1-365).
expires_at
body · string
Last day, YYYY-MM-DD, or null.
terms
body · string
New terms.
customer_name
body · string
Customer name.
customer_email
body · string
Customer email.
customer_phone
body · string
Customer phone.
customer_address
body · string
Customer address.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/estimates/ESTIMATE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "tax_rate": 8.25
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates/ESTIMATE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    tax_rate: 8.25,
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/estimates/ESTIMATE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "tax_rate": 8.25,
    },
)
print(res.json())
#

Delete an estimate

DELETE/v1/estimates/{estimate_id}
Read and writeIdempotent

One that became an invoice stays (409), so the invoice still points at it.

ParameterDescription
estimate_id
path · string · required
Estimate id.
Request
cURL
curl -X DELETE "https://api.ottopros.com/v1/estimates/ESTIMATE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates/ESTIMATE_ID", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.delete(
    "https://api.ottopros.com/v1/estimates/ESTIMATE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
#

Send an estimate to the lead

POST/v1/estimates/{estimate_id}/send
Read and writeIdempotent

Like the editor's Send: a text and/or an email to the estimate's lead with the link to review and approve it (on the sub-account's own domain when it has one), in the editor's own words unless you send message (it must include {link}). Sent as the key's person, with the usual opt-out, credit and limit checks; each is billed like any text or email. A draft (or a declined or expired one) becomes sent. sent lists each channel and why one didn't go.

ParameterDescription
estimate_id
path · string · required
Estimate id.
channel
body · string
Default both (whichever the lead has).
One of: sms, email, both
message
body · string
Your own wording, with {link}.
subject
body · string
The email's subject.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/estimates/ESTIMATE_ID/send" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "sms"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates/ESTIMATE_ID/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    channel: "sms",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/estimates/ESTIMATE_ID/send",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "channel": "sms",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "sent": [
      {
        "channel": "sms",
        "ok": true
      }
    ],
    "link": "https://book.acme.com/e/abc123",
    "status": "sent"
  },
  "error": null
}
#

Make the invoice

POST/v1/estimates/{estimate_id}/convert
Read and writeIdempotent

Convert to invoice: a draft invoice from the option the customer picked (else the recommended one, or option_id), with its lines, the estimate's tax rate and the sub-account's due days, terms and notes; the estimate becomes converted. Owners and admins (invoices are theirs). Send the invoice from the app.

ParameterDescription
estimate_id
path · string · required
Estimate id.
option_id
body · string
The option to invoice.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/estimates/ESTIMATE_ID/convert" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/estimates/ESTIMATE_ID/convert", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/estimates/ESTIMATE_ID/convert",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)
print(res.json())
Response 201
JSON
{
  "success": true,
  "data": {
    "estimate_id": "uuid",
    "status": "converted",
    "invoice": {
      "id": "uuid",
      "number": "INV-000124",
      "status": "draft",
      "total_cents": 49900
    }
  },
  "error": null
}
#

Review pages

GET/v1/review-funnels
Read only

Tools → Review funnel: each review page, which one is the default, its rating threshold, where happy customers are sent, and its public_path (/r/<code> on the sub-account's domain).

Request
cURL
curl "https://api.ottopros.com/v1/review-funnels?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/review-funnels?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/review-funnels?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Who was asked for a review, and what they did

GET/v1/review-requests
Read only

Sent, opened, rated (1-5), clicked through to a review site, and any private feedback, most recently active first.

ParameterDescription
funnel_id
query · string
One review page.
lead_id
query · string
One lead.
rated
query · boolean
Only rated (true) or not yet rated (false).
since
query · string
ISO time, changed at or after.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/review-requests?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/review-requests?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/review-requests?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())

Workspaces

#

List sub-accounts

GET/v1/workspaces
Read only
Request
cURL
curl "https://api.ottopros.com/v1/workspaces" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspaces", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspaces",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspaces": [
      {
        "id": "ws-uuid",
        "name": "Sunrise HVAC",
        "slug": "sunrise-hvac",
        "timezone": "America/Phoenix",
        "role": "owner"
      }
    ]
  },
  "error": null
}
#

Get one sub-account

GET/v1/workspaces/{workspace_id}
Read only
Request
cURL
curl "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspaces/WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace": {
      "id": "ws-uuid",
      "name": "Sunrise HVAC",
      "slug": "sunrise-hvac",
      "industry": "hvac",
      "timezone": "America/Phoenix",
      "brand_id": "brand-uuid"
    }
  },
  "error": null
}
#

Update a sub-account

PATCH/v1/workspaces/{workspace_id}
Read and writeIdempotent

Send only what changes: name, timezone, industry, logo_url, brand_id, archived, notify_on_appointments, enabled_features, hidden_features. logo_url is the sub-account's Client logo, the same image as Settings → Client logo & app icon: changing either changes both (null removes it). enabled_features is Settings > Features: tools only some businesses use, off until turned on (today: sales, the Sales page and the parts / tip / technician on won jobs; plans, Plans & memberships on won jobs); owners and admins. hidden_features is what the agency hides from the sub-account's own people (client admins, Staff and Technicians lose those pages, and their API keys get 403 feature_off on those routes): leads, messages, appointments, calendars, smart_forms, media, campaigns, calls, ai_voice, automations, landing_pages, invoices, sales, activity, team, ad_insights, tools, support, agent. archived, brand_id and hidden_features are the agency's: the owner or an agency admin. Both take { key: true | false } and change only the keys sent.

ParameterDescription
logo_url
body · string
Client logo URL, or null to remove it.
enabled_features
body · object
Settings > Features, e.g. { "sales": true }.
hidden_features
body · object
Hidden from the client's people, e.g. { "invoices": true, "team": false }. Owner or agency admin.
Request
cURL
curl -X PATCH "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "enabled_features": {
      "sales": true
    }
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspaces/WORKSPACE_ID", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    enabled_features: {
      sales: true,
    },
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.patch(
    "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "enabled_features": {
            "sales": True,
        },
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace": {
      "id": "ws-uuid",
      "name": "Sunrise HVAC & Plumbing",
      "industry": "hvac_plumbing"
    }
  },
  "error": null
}
#

Configure outreach (auto-greet, AI replies, sending identity)

POST/v1/workspaces/{workspace_id}/outreach
Read and writeIdempotent

Raw column names, for anyone who can manage the sub-account; PATCH on the same path does the same. Prefer PATCH /v1/ai-texting and /v1/nurture-sequence, which take friendly names and check every value. Accepted fields: auto_greet_new_leads, first_message_template, sms_template, greet_delay_minutes_smart_form, greet_delay_minutes_default, greet_window_mode / _start / _end, followup_window_mode / _start / _end, ai_enabled, ai_system_prompt, ai_business_context, ai_agent_name, ai_booking_link, ai_max_auto_replies, reply_delay_minutes, respect_business_hours, quiet_hours_enabled / _start / _end, handoff_on_unknown, handoff_pause_ai, handoff_resume_days, ai_can_cancel_appointments, ai_cancel_retention_enabled, ai_cancel_retention_note, ai_auto_resume_minutes, nurture_enabled, nurture_steps, email_enabled, email_from_address, email_from_name, email_subject, email_template, sms_enabled, missed_call_text_enabled, missed_call_text_body, reminder_24h_enabled / _template, reminder_1h_enabled / _template, reminder_5m_enabled / _template (the 5-minute reminder, off by default), customer_confirmation_enabled / _template, appointment_email_enabled, appointment_email_language (en | es). Unknown fields are ignored; text is capped at 20,000 characters.

ParameterDescription
auto_greet_new_leads
body · boolean
Text new leads automatically.
first_message_template
body · string
The first text.
ai_enabled
body · boolean
AI replies on or off.
ai_agent_name
body · string
Name the AI texts as.
email_from_name
body · string
Sender name on emails.
Request
cURL
curl -X POST "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID/outreach" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "auto_greet_new_leads": true,
    "ai_enabled": true,
    "ai_agent_name": "Sarah"
  }'
JavaScript
const res = await fetch("https://api.ottopros.com/v1/workspaces/WORKSPACE_ID/outreach", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    auto_greet_new_leads: true,
    ai_enabled: true,
    ai_agent_name: "Sarah",
  }),
});
const { success, data, error } = await res.json();
Python
import os
import uuid

import requests

res = requests.post(
    "https://api.ottopros.com/v1/workspaces/WORKSPACE_ID/outreach",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "auto_greet_new_leads": True,
        "ai_enabled": True,
        "ai_agent_name": "Sarah",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "outreach": {
      "workspace_id": "ws-uuid",
      "auto_greet_new_leads": true,
      "nurture_enabled": true
    }
  },
  "error": null
}

Ads & Google

#

The sub-account's Ad Insights

GET/v1/ad-insights
Read only

Exactly what its Ad Insights page shows: spend, leads, cost per lead, impressions and clicks for the Meta ad accounts attached to it (one, or all of them added up with each account's share in accounts, and an error for one that couldn't be read), each read with whichever connected Facebook profile can; per campaign and per ad, only the campaigns and ads the agency picked for this sub-account; ads left out (ads_hidden) when the agency hides them from the client. Amounts are added up in each account's own currency (accounts_meta). 403 ad_insights_off when Ad Insights is off for it.

ParameterDescription
ad_account_id
query · string
"all" (default) or one attached account, act_123.
date_preset
query · string
Default last_30d.
One of: today, yesterday, last_7d, last_14d, last_30d, last_90d, this_month, last_month, maximum
since
query · string
YYYY-MM-DD, with until (instead of date_preset).
until
query · string
YYYY-MM-DD.
campaign_id
query · string
Only this campaign's ads.
Request
cURL
curl "https://api.ottopros.com/v1/ad-insights?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/ad-insights?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/ad-insights?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "workspace_id": "uuid",
    "summary": {
      "total_spend": 1240.5,
      "total_leads": 62,
      "avg_cost_per_lead": 20.01,
      "total_impressions": 88120,
      "total_clicks": 1904
    },
    "campaigns": [
      {
        "id": "120210000000",
        "name": "Roof leads - Dallas",
        "effective_status": "ACTIVE",
        "spend": 820.1,
        "leads": 44,
        "impressions": 60110,
        "clicks": 1302
      }
    ],
    "ads": [],
    "account_id": "all",
    "account_ids": [
      "act_123",
      "act_456"
    ],
    "accounts_meta": {
      "act_123": {
        "name": "Acme Roofing",
        "currency": "USD"
      }
    },
    "accounts": [
      {
        "id": "act_123",
        "total_spend": 820.1,
        "total_leads": 44,
        "campaigns": 3,
        "running": 2,
        "daily_budget": 40
      }
    ],
    "window": {
      "date_preset": "last_30d"
    },
    "filtered": true,
    "ads_hidden": false
  },
  "error": null
}
#

Google Business Profiles

GET/v1/google-business/profiles
Read only

Every Business Profile connected to the sub-account (it can have several): the location, rating, review count, whether Google's API answers for it, and its auto-reply, auto-post and photo settings; plus the Google logins connected (email only). Owners and admins.

Request
cURL
curl "https://api.ottopros.com/v1/google-business/profiles?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/google-business/profiles?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/google-business/profiles?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Google reviews and replies

GET/v1/google-business/reviews
Read only

Reviews synced from Google every 15 minutes, newest first, with the reply sent, the draft waiting for approval and its status. Owners and admins.

ParameterDescription
profile_id
query · string
One profile.
status
query · string
Filter.
One of: new, draft, sending, sent, skipped, failed
unanswered
query · boolean
Only reviews without a reply.
max_stars
query · number
This many stars or fewer.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/google-business/reviews?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/google-business/reviews?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/google-business/reviews?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Google Business Profile posts

GET/v1/google-business/posts
Read only

Drafts, scheduled and published posts (title, text, button, offer, photo, when, the live link). Owners and admins.

ParameterDescription
profile_id
query · string
One profile.
status
query · string
Filter.
One of: draft, scheduled, publishing, published, failed
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/google-business/posts?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/google-business/posts?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/google-business/posts?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Profile Optimizer runs

GET/v1/google-business/optimizer
Read only

Each Profile Optimizer check, newest first: the score, what it found, the keywords, its suggestions and the changes made from it (and undone). Owners and admins.

ParameterDescription
profile_id
query · string
One profile.
limit
query · number
1-25, default 5.
Request
cURL
curl "https://api.ottopros.com/v1/google-business/optimizer?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/google-business/optimizer?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/google-business/optimizer?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Map Rankings: keywords and scans

GET/v1/map-rankings
Read only

Where the business ranks on Google Maps across a grid around it: the keywords tracked (grid, schedule, next and last scan) and the scans, newest first, each with its summary (points, found, top 3, average rank). Owners and admins.

ParameterDescription
keyword_id
query · string
One keyword's scans.
profile_id
query · string
One profile.
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/map-rankings?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/map-rankings?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/map-rankings?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

One Map Rankings scan

GET/v1/map-rankings/scans/{scan_id}
Read only

Every grid point with its rank (null: not in the top 20), the competitors found, and the next steps to rank #1 once they've been written (they are written when the scan is first opened in the app). Owners and admins.

ParameterDescription
scan_id
path · string · required
Scan id.
Request
cURL
curl "https://api.ottopros.com/v1/map-rankings/scans/SCAN_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/map-rankings/scans/SCAN_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/map-rankings/scans/SCAN_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

Business Listings

GET/v1/listings
Read only

The directories the business is being listed on: each one's status (queued, scanning, listed, mismatch, not_listed, working, needs_you, submitted, live, owner, skipped, failed) with counts, what differs or what's needed from the owner, the listing link, and the latest activity. Passwords and verification codes are never returned. Owners and admins.

Request
cURL
curl "https://api.ottopros.com/v1/listings?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/listings?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/listings?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())

Support

#

Support tickets

GET/v1/support/tickets
Read only

The sub-account's Support tickets, newest activity first: subject, category, status (open, answered, resolved), who is answering (the assistant or the team), unread state and rating. The agency team sees every ticket; a client admin every ticket of the sub-account; anyone else their own. 403 feature_off while Support is off for the sub-account.

ParameterDescription
status
query · string
Filter.
One of: open, answered, resolved
limit
query · number
1-100, default 25.
cursor
query · string
next_cursor from the previous page.
Request
cURL
curl "https://api.ottopros.com/v1/support/tickets?workspace_id=WORKSPACE_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/support/tickets?workspace_id=WORKSPACE_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/support/tickets?workspace_id=WORKSPACE_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
#

One support ticket and its messages

GET/v1/support/tickets/{ticket_id}
Read only

Every message in order (the customer, the assistant, the team), with the files' names. Internal notes only for the agency team.

ParameterDescription
ticket_id
path · string · required
Ticket id.
Request
cURL
curl "https://api.ottopros.com/v1/support/tickets/TICKET_ID" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/support/tickets/TICKET_ID", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/support/tickets/TICKET_ID",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())

Account

#

Inspect the calling API key

GET/v1/keys/self
Read only

What this key may do and how much of it is used: access_level (read / write / admin / custom) and scopes, the workspace it is locked to (if any), rate_limit_per_min, credit_budget (limit, used, remaining and reset date; null when the key has no budget) and usage_30d (requests, errors, rate_limited). type is account (made in Settings → API) or sub_account (made in a sub-account's own Settings → API in the client CRM), and endpoints gives the API and MCP addresses for the host you called. A sub-account key can call this.

Request
cURL
curl "https://api.ottopros.com/v1/keys/self" \
  -H "Authorization: Bearer $API_KEY"
JavaScript
const res = await fetch("https://api.ottopros.com/v1/keys/self", {
  headers: {
    Authorization: `Bearer ${process.env.API_KEY}`,
  },
});
const { success, data, error } = await res.json();
Python
import os

import requests

res = requests.get(
    "https://api.ottopros.com/v1/keys/self",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
    },
)
print(res.json())
Response 200
JSON
{
  "success": true,
  "data": {
    "key": {
      "id": "uuid",
      "key_prefix": "zu-ab12",
      "name": "Zapier",
      "is_active": true,
      "access_level": "write",
      "scopes": [
        "write"
      ],
      "workspace": {
        "id": "uuid",
        "name": "Acme Roofing"
      },
      "rate_limit_per_min": 120,
      "credit_budget": {
        "limit_per_month": 500,
        "used_this_month": 88,
        "remaining": 412,
        "resets_at": "2026-10-01T00:00:00Z"
      },
      "usage_30d": {
        "requests": 1830,
        "errors": 12,
        "rate_limited": 0
      },
      "last_used_at": "2026-09-30T15:00:00Z",
      "created_at": "2026-09-01T00:00:00Z",
      "type": "account"
    },
    "endpoints": {
      "api": "https://api.ottopros.com/v1",
      "mcp": "https://api.ottopros.com/mcp"
    }
  },
  "error": null
}