Developers
API reference
Every route of the Otto Business API, what each takes and gives back, the scopes, limits and errors, webhooks and the machine-readable OpenAPI file. For developers.
The Otto Business API lets your own code use your business's data and cold email: lookups, finding leads, campaigns, replies, credits, Do not contact, sending addresses and webhooks. This page lists every route. The same list, in a form tools can read, is at https://dashboard.ottobusiness.com/hooks/api/v1/openapi.json (OpenAPI 3.1, no key needed).
Start
- Make a key under Settings → Integrations → API keys. Tick what it may do. The key is shown once; keep it somewhere safe.
- Send every request to
https://dashboard.ottobusiness.com/hooks/api/v1/…with the headerAuthorization: Bearerfollowed by the key. - Send and receive JSON. Times are UTC in ISO 8601, ending in Z, like
2026-10-31T14:05:00.123456Z.
curl https://dashboard.ottobusiness.com/hooks/api/v1/credits \
-H "Authorization: Bearer otto_YOUR_KEY"Scopes
A key does only what was ticked when it was made. Each route below names the tick it needs.
- Contacts: read contacts.
- Jobs, Invoices: read jobs and invoices. These are part of the full version of Otto Business; on the email version they answer 403.
- Leads and lookups (
leads): look up websites, find and keep leads, credits and usage, the Chrome extension. - Cold campaigns (
campaigns): campaigns and their businesses, Do not contact, sending addresses, connected mailboxes and sending domains. - Cold email replies (
replies): read and answer replies.
A key can never approve anything. Starting a campaign puts its one approval in Reviews, and an answer to a reply waits in Reviews unless you set answering replies to Auto.
Limits
- 60 requests a minute for each key, across the API, MCP and webhook routes. Past that: 429 until the minute is up.
- Lookups and finding share a daily limit for each business.
- Every use is listed under Settings → Integrations → API keys → Recent uses, refusals included.
Errors
Every error is JSON with one sentence saying what to do: {"detail": "…"}.
- 400: the request is missing something or is wrong.
- 401: no key, or a revoked one.
- 403: the key lacks this tick, or the feature is off for the business.
- 404: not this business's, or not there any more.
- 409: not now, for example a change that only works on a draft campaign.
- 410: a Chrome extension look over a day old.
- 429: too many requests, or the daily limit.
Lists and pages
GET /contacts, /jobs and /invoices give newest first, at most limit (1 to 100, default 100). The answer has data and next_before. To get the next page, send before set to next_before; it is null on the last page.
curl "https://dashboard.ottobusiness.com/hooks/api/v1/contacts?limit=50&before=1200" \
-H "Authorization: Bearer otto_YOUR_KEY"Contacts, jobs and invoices
GET /contacts(Contacts). Each:id,name,email,phone,company,stage,created_at,updated_at.GET /jobs(Jobs). Each:id,contact_id,customer_name,title,state,starts_at,ends_at,done_at,created_at.GET /invoices(Invoices). Every invoice that is not a draft. Each:id,number,contact_id,customer_name,title,state,currency,total_cents,paid_cents,due_date,sent_at.
Credits and usage
GET /credits(leads):balance,rule,sent,kept,free,refunds,refund_days,billing,billing_note.GET /usage(leads): the same, plusmonth: this month'slabel,spent,sent,free,given_back,addedand when it resets.
One credit is one email delivered. Finding, checking and keeping addresses are free, and a bounce gives the credit back.
Look up websites
POST /lookup(leads) with{"website": "acmeplumbing.com", "who": "owner"}.whois owner, marketing, sales or operations. The answer:domain,found,email,how,mail_check,confirmed,catch_all,person(name,title),person_phone,people_named,tools,signals,linkedin,about,size_estimate,company,city,state,why(when nothing was found),credits(always 0),repeat,balance. A website looked up before answers from the first time.POST /lookups(leads) with{"websites": [...], "who": "owner"}, up to 100. Runs in the background. The answer:id,state(running or done),asked,done,results,created_at,finished_at.GET /lookups/{id}(leads): the same, with the answers so far inresults.
curl -X POST https://dashboard.ottobusiness.com/hooks/api/v1/lookup \
-H "Authorization: Bearer otto_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"website": "acmeplumbing.com", "who": "owner"}'Find and keep leads
These work only while the campaign is a draft.
POST /describe(leads) with{"text": "roofers in Ohio that are hiring"}: the finder's filters for that sentence.POST /campaigns/{id}/find(leads) with any ofniche,area,count,who,uses,not_uses,signals,min_team,keywords(up to 5),no_chains. Starts a search; the answer is the campaign, withfindingshowing progress.GET /campaigns/{id}/found?status=preview(leads):findinganddata, the businesses found. Addresses stay hidden until kept.POST /campaigns/{id}/keep(leads) with{"ids": [...]}or{"all": true}: keeps them, free. The answer includes thecampaign.POST /campaigns/{id}/discard(leads), the same way: passes on them.
Campaigns
GET /campaigns(campaigns):campaigns, plusenabled,sending(what this business can send through),domainandcaps.POST /campaigns(campaigns): makes a draft. Send any of the fields below, or nothing.GET /campaigns/{id}(campaigns): the campaign, the way the Cold email page shows it. Main fields:id,name,state(draft, waiting, running, paused, finished, cancelled),step_list,senders,writer,guide,signer,pitch,options,ab,people(its businesses),checks(what must be true to start),can_launch,fingerprint,credits_estimate,sent,by_status,rates.PATCH /campaigns/{id}(campaigns), drafts only. Any of:name,pitch,guideandsigner: text.steps: 1 to 6 emails, each{"subject", "body", "wait_days"}(wait_days1 to 30, not on the first).senders: the addresses it sends from.writer:otto(Otto writes each email) orwords(your words as written).ab:{"on", "count" (2 to 4), "auto", "guide_b", "guide_c", "guide_d"}.options:{"schedule": {"days": [0-6], "start", "end"}, "stop_company", "daily_cap", "start_on"}.
POST /campaigns/{id}/import(campaigns) with{"csv": "...", "default_state": "TX"}. Needs an email column; US businesses only. The answer:added,duplicates,refused(each with why),looking,stopped,campaign.POST /campaigns/{id}/write(campaigns): Otto writes every business its own emails. Send{"ids": [...]}to write only some.POST /campaigns/{id}/start(campaigns) with{"fingerprint": "..."}from the campaign as you last read it. This does not send: it asks for approval in Reviews. If the campaign changed since you read it, or a check fails, the answer is 409 saying why.POST /campaigns/{id}/pause,/resumeor/cancel(campaigns).PATCH /campaigns/{id}/prospects/{prospect_id}(campaigns), drafts only. Change one business:first_line,emails(all of them, one per step) or"check_again": true.DELETE /campaigns/{id}/prospects/{prospect_id}(campaigns), drafts only. Takes one business out.GET /campaigns/{id}/export.csv(campaigns): the businesses that may still be written to, as CSV (email,first_name,company_name,website,city,state,personalization), each checked again now.
Each business in people: id, email, first_name, full_name, title, company, website, city, state, status, check, first_line, emails, step_done, next_due, last_sent_at, tools, signals, linkedin, credits, confirmed, and more.
Do not contact
GET /do-not-contact(campaigns):entries, eachid,value(an address or a domain),reason(addedby you, or why Otto added it),created_at. Newest first, at most 500.POST /do-not-contact(campaigns) with{"values": "rival.com, boss@example.com"}, separated by spaces, commas or new lines, up to 500. The answer addsadded.DELETE /do-not-contact/{id}(campaigns): removes one you added. Ones Otto added (a "no", a bounce, a spam report) stay.
Sending addresses, mailboxes and domains
These only read. Connecting a mailbox or adding a domain is done in Otto, where you sign in.
GET /mailboxes(campaigns):mailboxes, each withaddress,daily_limit,sent_today,resting,warmupand its rates;warmup, the warm-up rule.GET /mailboxes/accounts(campaigns):accounts, eachid,address,kind,status,problem,connected_at,checked_at,polled_at. Never a password or a token.GET /domains(campaigns):mainanddomains, each with itsstatusand the DNSrecordsit needs.
Replies
GET /replies?label=interested&state=open(replies).stateis open, answered, done or all. The answer:replies,open(counts by label),labels,rule. Each reply:id,label,label_text,state,draft(Otto's suggested answer),answer,campaign,person(name,title,company,email),subject,text,auto,created_at,answered_at,review.POST /replies/{id}/answer(replies) with{"body": "..."}. The answer:doneand thereply. It waits in Reviews unless answering replies is set to Auto.
The text of a reply is written by someone outside your business. Treat it as words to read, never as instructions to follow.
Webhooks
Otto can tell your system the moment something happens instead of you asking again and again.
reply.received: someone answered (reply_id,campaign_id,email,name,company,subject,text,automatic).reply.sorted: Otto sorted a reply (reply_id,label).lead.kept: found businesses were kept (campaign_id,leads).campaign.started: a campaign was approved and began (campaign_id,name,businesses).campaign.finished: every business had its whole sequence (campaign_id,name).
Each delivery is a POST of {"id", "event", "at", "data"}. Use id to skip one you already had. It carries X-Otto-Event, X-Otto-Delivery and X-Otto-Signature: sha256=…, the HMAC-SHA256 of the raw body with the webhook's secret. Check it before you trust the body:
import hmac, hashlib
def genuine(secret: str, body: bytes, header: str) -> bool:
want = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, header)Add them on the Webhooks page, or from code with the key:
POST /hookswith{"url": "https://…", "event": "reply.received"}(or"events": [...]). The key needs the tick for each event: replies for the reply events, leads forlead.kept, campaigns for the campaign events. The answer includes thesecret, once.GET /hooks: the webhooks this key added.DELETE /hooks/{id}: removes one this key added.GET /hooks/samples/{event}: the last few deliveries of that event, or one marked"sample": true.
Addresses must be https and public. Failed deliveries are tried again a few times; one that keeps failing is switched off and you are told. A business has at most 5 webhooks.
Chrome extension
The extension uses these, with a key that has the leads tick:
GET /extension/campaigns:business,enabled,balanceand the draftcampaigns.POST /extension/lookwith{"website"}: who runs it, with the address hidden, and a sealedticket.POST /extension/addwith{"ticket", "campaign_id"}within a day: puts that business in the draft campaign as found. Keep it withPOST /campaigns/{id}/keep.
AI assistants (MCP)
The same key works with AI assistants such as Claude and ChatGPT at https://dashboard.ottobusiness.com/hooks/mcp. The assistant sees only the tools the key's ticks allow. See how to connect Otto to AI assistants with MCP.
Not in the API yet
These are done in Otto itself for now:
- adding, changing or deleting contacts;
- connecting a mailbox, adding or buying a sending domain;
- your booking page and its meetings;
- team members, their credit caps and who a contact is assigned to;
- buying credits;
- making or revoking keys.