instaoutreach API
HTTP API

API reference

Everything an engineer needs to drive instaoutreach from your own systems: enroll leads, read the funnel, pull conversations, and receive qualified replies on your webhook. JSON over HTTPS, one header of auth, no SDK required.

https://instaoutreach.com JSON · UTF-8 TLS only Examples verified against production 2026-07-27

Overview

All requests go to https://instaoutreach.com over HTTPS. Send and expect application/json. Timestamps are ISO 8601 UTC, ids are UUIDs.

Every credential resolves to exactly one organization, and every query is scoped to it. There is no organization parameter you can change to reach another tenant.

Authentication

Two credential types. Both are stored as a SHA-256 hash, shown once at creation, and revocable instantly.

Authorization: Bearer iom_… self-serve

Mint it in Settings → MCP access. Covers the Integration API and the MCP endpoint. Two scopes:

ScopeGrants
readEvery read tool + the read endpoints. Cannot change anything.
operatorRead plus enroll, warmup, proxy and campaign-draft writes. Never deletes.
curl -sS https://instaoutreach.com/api/ig/campaigns \ -H "Authorization: Bearer iom_your_token"
X-API-Key: igk_… issued on request

The org data API. Keys are provisioned by an operator (there is no self-serve screen) and carry a role:

RoleGrants
readonlyGET endpoints only. Any write returns 403 readonly api key.
operatorRead + write on its own org.
adminSame HTTP surface as operator (role recorded for audit).
ownerSame HTTP surface as operator (role recorded for audit).
curl -sS https://instaoutreach.com/orgs/acme/stats \ -H "X-API-Key: igk_your_key"

A missing credential returns 401; a valid credential pointed at another organization returns 403. Never ship either credential to a browser or a mobile app. Both are full-privilege server-side secrets.

Service

no auth

No authentication. Safe to hit from a monitor.

GET /health 600 req/min per IP

Liveness + database probe.

Unauthenticated. Use it for uptime monitors and readiness gates. db is false when the engine is up but Postgres is unreachable, but the HTTP status stays 200, so alert on the body, not the code.

Response
{ "status": "ok", "db": true }
Response fields
FieldTypeDescription
statusstringAlways ok when the process is serving.
dbbooleantrue when SELECT 1 against Postgres succeeded.
Example
curl -sS -X GET https://instaoutreach.com/health

Integration API

Authorization: Bearer

Bearer token (iom_). Self-serve: mint one in Settings, MCP access tab. This is the surface most integrations need.

GET /api/ig/campaigns read scope 300 req/min per token

List the campaigns in your organization.

Returns every campaign the token's org owns, newest first. Use it to pick a campaign name before enrolling leads.

Response
{ "campaigns": [ { "id": "6f1c…", "name": "Sirius leads", "status": "running" } ] }
Response fields
FieldTypeDescription
campaigns[].iduuidCampaign id.
campaigns[].namestringUnique per org. This is what enroll matches on.
campaigns[].statusstringdraft · running · paused · done.
Example
curl -sS -X GET https://instaoutreach.com/api/ig/campaigns \ -H "Authorization: Bearer iom_your_token"
POST /api/ig/enroll operator scope 5000 leads/min per token

Enroll Instagram handles into an outreach campaign.

The main write endpoint. Each lead becomes a campaign target. Pass external_ref = the row id in your own system and every funnel transition (queued → engaged → dm_sent → replied) is pushed back to that row. The campaign is created on first use if it does not exist; it starts as a draft, so nothing sends until you launch it in the dashboard.

Parameters
NameTypeRequiredDescription
campaign string no Campaign name. Defaults to Sirius leads.
leads[] array yes Up to 500 per call. More than that is rejected with 413, never silently truncated.
leads[].ig_username string yes Handle, with or without a leading @.
leads[].external_ref string no Your row id: a UUID or positive integer, optionally prefixed leads: or influencers:. Anything else is skipped with bad_external_ref.
Request body
{ "campaign": "Sirius leads", "leads": [ { "ig_username": "taylor_demo", "external_ref": "leads:3f2a1c60-1b2e-4d5a-9f01-8c7b6a5d4e3f" } ] }
Response
{ "campaign": "Sirius leads", "enrolled": 1, "skipped": 0, "details": { "skipped": [] } }
Response fields
FieldTypeDescription
enrolledintegerLeads accepted into the campaign.
skippedintegerLeads rejected. See details.skipped.
details.skipped[].reasonstringno_username (blank handle) or bad_external_ref (id failed validation).
Example
curl -sS -X POST https://instaoutreach.com/api/ig/enroll \ -H "Authorization: Bearer iom_your_token" \ -H "Content-Type: application/json" \ -d '{ "campaign": "Sirius leads", "leads": [ { "ig_username": "taylor_demo", "external_ref": "leads:3f2a1c60-1b2e-4d5a-9f01-8c7b6a5d4e3f" } ] }'
  • A single lead can be posted flat: {"ig_username": "…", "external_ref": "…"}.
  • Re-enrolling a handle already in the campaign is an upsert, not a no-op: it counts as enrolled and OVERWRITES the stored external_ref, so the funnel then reports to the new id. Send a stable ref per handle.
  • Quota is charged per lead, not per request: 5000 leads/min per token.

MCP endpoint

Authorization: Bearer

Same Bearer token. JSON-RPC 2.0 for AI clients.

POST /mcp read scope 120 messages/min per token

MCP (Model Context Protocol) endpoint: JSON-RPC 2.0 over HTTP.

Streamable-HTTP transport. One JSON-RPC request in, one JSON response out; arrays are accepted as batches. Methods: initialize, ping, tools/list, tools/call. Write tools require an operator token. The org is resolved from the token, so no request ever carries an org id.

Request body
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_accounts", "arguments": {} } }
Response
{ "jsonrpc": "2.0", "id": 1, "result": { "isError": false, "content": [ { "type": "text", "text": "{ … }" } ] } }
Example
curl -sS -X POST https://instaoutreach.com/mcp \ -H "Authorization: Bearer iom_your_token" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_accounts", "arguments": {} } }'
  • Full tool list, client setup and scopes: see the MCP docs page.
  • Rate limit: 120 messages/min per token. A batch is charged one unit per message, so 50 messages cost 50.
  • Batches carry at most 50 messages; more returns 413.
  • Notifications (no id) return 202 with an empty body.
GET /mcp read scope no quota charged

Returns 405. No server-push stream is offered.

Spec-compliant: the server does not open an SSE channel, so clients must use POST. Some MCP clients probe with GET first; a 405 here is expected, not a fault.

Response
405 Method Not Allowed Allow: POST
Example
curl -sS -X GET https://instaoutreach.com/mcp \ -H "Authorization: Bearer iom_your_token"

Org data API

X-API-Key

X-API-Key header (igk_). Read-and-control surface for dashboards and CRM connectors. Keys are issued by an operator on request, not self-serve.

GET /orgs/{org_slug}/stats 600 req/min per key

Counters for one organization.

Account counts by status, campaign-target counts by funnel state, plus two queue depths. Cheap enough to poll on a dashboard.

Response
{ "accounts": { "active": 2, "warming": 1 }, "targets": { "queued": 40, "dm_sent": 12, "replied": 3 }, "unread_threads": 3, "pending_leads": 0 }
Response fields
FieldTypeDescription
accountsobjectAccount count keyed by status.
targetsobjectTarget count keyed by state: queued · engaged · dm_sent · replied · failed · skipped.
unread_threadsintegerThreads with an unread inbound message.
pending_leadsintegerQualified replies still waiting in the export outbox.
Example
curl -sS -X GET https://instaoutreach.com/orgs/acme/stats \ -H "X-API-Key: igk_your_key"
GET /orgs/{org_slug}/accounts 600 req/min per key

Sending accounts with health and warmup state.

Never returns the account password or 2FA secret. Those columns are not in the projection at all.

Response
[ { "id": "b21e…", "username": "taylor_demo", "status": "active", "warmup_day": 21, "health_score": 98, "paused_until": null, "last_action_at": "2026-07-27T08:14:02Z", "last_login_at": "2026-07-26T19:02:44Z", "proxy_id": "9c04…", "tags": ["pl"] } ]
Response fields
FieldTypeDescription
statusstringnew · warming · active · paused · banned.
warmup_dayintegerDay index in the warmup ramp.
health_scoreinteger0-100. Drops on blocks, challenges and failures.
paused_untiltimestamp|nullSet by the backoff layer after a platform block.
Example
curl -sS -X GET https://instaoutreach.com/orgs/acme/accounts \ -H "X-API-Key: igk_your_key"
GET /orgs/{org_slug}/campaigns 600 req/min per key

Full campaign rows for the org, newest first.

Same campaigns as /api/ig/campaigns, with the configuration columns (offer, prompt, pacing) included.

Response
[ { "id": "6f1c…", "name": "Sirius leads", "status": "running", "offer": "…", "engagement_action": "like", "engage_to_dm_hours": 20, "daily_dm_target": 0 } ]
Response fields
FieldTypeDescription
engagement_actionstringlike · comment · none.
engage_to_dm_hoursintegerMinimum spacing between engaging a target and DMing it.
daily_dm_targetinteger0 falls back to the per-account cap.
Example
curl -sS -X GET https://instaoutreach.com/orgs/acme/campaigns \ -H "X-API-Key: igk_your_key"
GET /orgs/{org_slug}/inbox 600 req/min per key

Conversation threads, most recent activity first.

Each row carries the sending account's username and a snippet of the latest message, so a list view needs no follow-up call.

Parameters
NameTypeRequiredDescription
unread_only boolean no Default false. true returns only unread threads.
limit integer no Default 100, clamped to 500.
Response
[ { "id": "a7d3…", "target_username": "some_lead", "account_username": "taylor_demo", "unread": true, "last_intent": "interested", "last_text": "sure, send details", "last_direction": "in", "state": "open" } ]
Response fields
FieldTypeDescription
last_intentstring|nullinterested · question · booking · objection · not_interested · neutral.
last_directionstringin (from the lead) or out (from your account).
statestringopen or closed.
Example
curl -sS -X GET https://instaoutreach.com/orgs/acme/inbox \ -H "X-API-Key: igk_your_key"
GET /threads/{thread_id}/messages 600 req/min per key

Every message in one thread, oldest first.

The thread must belong to the key's org, otherwise 403. Capped at 200 messages.

Response
[ { "id": "11aa…", "direction": "out", "text": "hey, quick question…", "intent": null, "created_at": "2026-07-26T10:00:00Z" } ]
Response fields
FieldTypeDescription
directionstringin · out.
intentstring|nullClassifier output. Set on inbound messages only.
classificationobjectRaw classifier payload (confidence, summary, source).
Example
curl -sS -X GET https://instaoutreach.com/threads/{thread_id}/messages \ -H "X-API-Key: igk_your_key"
POST /threads/{thread_id}/read 60 req/min per key

Mark a thread read.

Requires a non-readonly key. Idempotent.

Response
{ "status": "ok" }
Example
curl -sS -X POST https://instaoutreach.com/threads/{thread_id}/read \ -H "X-API-Key: igk_your_key"
POST /orgs/{org_slug}/leads/flush 60 req/min per key

Drain the qualified-reply outbox to your webhook now.

Normally a scheduled beat drains it. Call this to force a flush after fixing a webhook outage. Pending rows survive downtime, so nothing is lost. Requires a non-readonly key.

Parameters
NameTypeRequiredDescription
limit integer no Rows to drain in this call. Default 50, clamped to 500.
Response
{ "results": [ { "id": "c0de…", "status": "sent", "external_id": "1042" } ] }
Response fields
FieldTypeDescription
results[].statusstringsent · failed (error recorded, stays pending) · skipped (no webhook configured).
Example
curl -sS -X POST https://instaoutreach.com/orgs/acme/leads/flush \ -H "X-API-Key: igk_your_key"
GET /orgs/{org_slug}/setter/drafts 600 req/min per key

Appointment-setter replies awaiting human approval.

The setter never sends on its own. It drafts, you approve. This is the queue.

Parameters
NameTypeRequiredDescription
status string no Default pending.
Response
[ { "id": "d1af…", "thread_id": "a7d3…", "text": "happy to jump on a call…", "status": "pending", "created_at": "2026-07-27T07:40:11Z" } ]
Example
curl -sS -X GET https://instaoutreach.com/orgs/acme/setter/drafts \ -H "X-API-Key: igk_your_key"
POST /setter/drafts/{draft_id}/approve 60 req/min per key

Approve a draft and queue it to send.

Requires a non-readonly key. The send still passes every anti-ban gate (active window, rate caps, per-account serial lock).

Response
{ "status": "queued" }
Example
curl -sS -X POST https://instaoutreach.com/setter/drafts/{draft_id}/approve \ -H "X-API-Key: igk_your_key"
POST /setter/drafts/{draft_id}/reject 60 req/min per key

Discard a draft.

Requires a non-readonly key.

Response
{ "status": "rejected" }
Example
curl -sS -X POST https://instaoutreach.com/setter/drafts/{draft_id}/reject \ -H "X-API-Key: igk_your_key"
POST /accounts/login 60 req/min per key

Queue a platform login for one sending account.

Asynchronous: returns a Celery task id immediately. The worker logs in through the account's pinned proxy and frozen device fingerprint. Credentials are never sent over the API. They are stored encrypted and read by the worker. Requires a non-readonly key and an active subscription.

Request body
{ "org_slug": "acme", "username": "taylor_demo" }
Response
{ "task_id": "7e2c…" }
Example
curl -sS -X POST https://instaoutreach.com/accounts/login \ -H "X-API-Key: igk_your_key" \ -H "Content-Type: application/json" \ -d '{ "org_slug": "acme", "username": "taylor_demo" }'
POST /dm/send 60 req/min per key

Queue one direct message.

Asynchronous. The message is still subject to every server-side guardrail: warmup gating, daily cap, active window, per-account serial lock and the abuse circuit breaker. There is no bulk variant by design. Requires a non-readonly key and an active subscription. text is capped at 2000 characters.

Request body
{ "org_slug": "acme", "username": "taylor_demo", "to_username": "some_lead", "text": "hey, quick question about your studio" }
Response
{ "task_id": "9b41…" }
Response fields
FieldTypeDescription
task_idstringCelery task id. The send result lands in the activity log, not here.
Example
curl -sS -X POST https://instaoutreach.com/dm/send \ -H "X-API-Key: igk_your_key" \ -H "Content-Type: application/json" \ -d '{ "org_slug": "acme", "username": "taylor_demo", "to_username": "some_lead", "text": "hey, quick question about your studio" }'

Outbound webhook

instaoutreach can push qualified replies to an endpoint you own.

Configured per deployment (SIRIUS_WEBHOOK_URL, optional SIRIUS_WEBHOOK_TOKEN sent as a Bearer header). Replies land in an outbox first, so an outage on your side never loses a lead. Rows stay pending until the next flush.

We POST
{ "source": "ig-outreach", "username": "some_lead", "intent": "booking", "summary": "wants a call this week", "message": "sure, send details", "ig_account": "taylor_demo" }
You respond

Respond 2xx. If the body is JSON containing an id, it is stored as the export's external_id for reconciliation.

Timeout & retry

15 s. Non-2xx or timeout marks the row failed with the error text; it is retried on the next flush.

Errors

Errors carry a JSON body with a single explanatory string: detail on the org data API, error on the integration API. That holds for 404s too: the marketing site serves an HTML 404 page, but every path listed here answers with JSON, so your client can decode a failure the same way it decodes a success.

StatusMessageMeaning
400bad json / no leadsThe body did not parse, or carried no usable lead.
401missing X-API-Key · invalid or revoked api key · unauthorizedNo credential, or one that has been revoked. Mint a new token.
402subscription inactiveThe org has no active subscription or trial. Reads keep working; writes are refused.
403readonly api key · operator token requiredThe credential is valid but not entitled to this verb. Wrong-org access answers 404, not 403.
404org not found · thread not found · draft not foundThe slug or id does not exist, or belongs to another org. The two are deliberately indistinguishable so ids cannot be probed.
413too many leads · batch too large · request body too largeOver a documented bound. Split the payload rather than retrying it.
429rate limitedOver the per-credential budget. The response carries Retry-After (seconds) plus X-RateLimit-Limit and X-RateLimit-Remaining. Wait that long, then retry.

JSON-RPC codes (/mcp)

CodeMessageMeaning
-32001unauthorizedMissing or invalid Bearer token (HTTP 401).
-32002rate limitedToken exceeded 120 calls/min (HTTP 429).
-32700parse errorBody was not valid JSON (HTTP 400).
-32601method not foundUnknown JSON-RPC method.

A tool that fails inside tools/call still returns HTTP 200 and JSON-RPC result, with isError: true and the reason in the text content. Check that flag, not just the status code.

Rate limits & guardrails

Budgets are per credential, per minute, and are charged by work done, not requests sent: a 50-message JSON-RPC batch costs 50, and a 500-lead enrolment costs 500. Pace against the unit in the table below, not against your request count.

SurfaceBudgetUnit
/mcp120 / minJSON-RPC messages (max 50 per batch)
/api/ig/enroll5000 / minleads (max 500 per request)
/api/ig/campaigns300 / minrequests
org data API, reads600 / minrequests
org data API, writes60 / minrequests

Handling a 429

Every 429 carries the numbers you need, so you never have to guess a backoff:

HTTP/1.1 429 Too Many Requests Retry-After: 37 X-RateLimit-Limit: 120 X-RateLimit-Remaining: 0

Sleep for Retry-After seconds and repeat the identical request. A refused request is not charged against your budget, so retrying costs you nothing extra and cannot dig you deeper.

import time, requests def call(method, url, **kw): for attempt in range(5): r = requests.request(method, url, **kw) if r.status_code != 429: return r time.sleep(int(r.headers.get("Retry-After", 5))) raise RuntimeError("still rate limited after 5 attempts")

429 vs 413

They mean opposite things. 429 is "too fast, wait" — the same request will succeed later. 413 is "too big, never" — waiting changes nothing, you have to split the payload. Retrying a 413 unchanged is an infinite loop.

Everything else

  • Hard bounds that return 413: 50 messages per JSON-RPC batch, 500 leads per enrolment, 1 MB per request body.
  • Writes need an active subscription or trial, and return 402 without one. Reads keep working, so you can always export your own data.
  • Paging limit is clamped to 500 server-side, and DM text to 2000 characters.
  • Sending guardrails are enforced in the engine, not the API: warmup gating, per-account daily caps, active-hours window, a serial lock per account and an abuse circuit breaker. An API caller cannot bypass any of them, and there is deliberately no bulk-DM endpoint.
  • Every write is written to the audit log with the calling credential's name.
  • Marking a read thread read or re-approving a draft will not duplicate work. Re-enrolling a handle is an upsert that overwrites its external_ref, so keep that id stable.
  • An id belonging to another organization answers 404, identically to an id that does not exist. You cannot use the API to learn whether another tenant's slug or thread id is real.
Need help?