Developers

Your Instagram contacts, in your own code

A small, predictable REST API: read your contacts and their conversations, tag them and set fields from your store or CRM, page through a segment, send a DM when something happens, record the sales your DMs bring in, and check what your automations are doing.

Included on every plan · JSON over HTTPS

REST API · API key in Settings → API
  • GET/api/v1/contacts?tag=vip
    List contacts, filtered by tag
  • POST/api/v1/contacts/:id/tags
    Add or remove tags
  • POST/api/v1/contacts/:id/send
    Send a DM inside the 24-hour window
POST /api/v1/contacts/:id/tags
{ "tags": ["vip"], "mode": "add" }
What you can build

Connect MuChat to the rest of your stack

Sync your list

Page through every contact, a tag or a saved segment into your CRM or spreadsheet.

Tag and enrich

Mark buyers as “customer” when an order lands and write the order value into a custom field.

Send on an event

Confirm a booking, or send the receipt as a file, in the DM thread they already started.

Keys you control

Read-only or read-write keys, named for what uses them, and revocable in one click.

Quickstart

Your first call in two minutes

  1. 1. Create a key

    In MuChat, open Settings → API, name the key after what will use it, and choose its scopes: read for every GET, write for every POST. The two are separate — a write-only key cannot read. The token starts with mu_live_ and is shown once — we only keep a hash, so store it somewhere safe. You can have up to 10 active keys.

  2. 2. Call the API

    Send the token as a bearer token. Every key belongs to one MuChat account and only ever sees that account’s data — no endpoint takes an account id, in a body or anywhere else.

    curl https://muchat.ai/api/v1/contacts?tag=vip&limit=10 \
      -H "Authorization: Bearer mu_live_…"
  3. 3. Page through results

    Every list returns { "data": [...], "nextCursor": … }. Pass nextCursor back as cursor until it comes back null, which is the only end-of-list signal — there is no total. Pages are 1–100 rows, 25 by default. The three endpoints that aren’t paged (/tags, /fields, /segments) return no nextCursor at all.

  4. 4. Send inside the window

    Instagram — not MuChat — only allows a free-form DM within 24 hours of the person’s last message to you. Sends outside it are refused with a 409 that tells you when the window closed. Nothing in this API can message someone who hasn’t messaged you.

Reference

Endpoints

Base URL https://muchat.ai/api/v1. Requests and responses are JSON; timestamps are ISO 8601 in UTC. Successful responses always put the payload under "data".

  • GET/contactsread
  • GET/contacts/:idread
  • GET/contacts/:id/messagesread
  • POST/contacts/:id/sendwrite
  • POST/contacts/:id/tagswrite
  • POST/contacts/:id/fieldswrite
  • GET/fieldsread
  • GET/tagsread
  • GET/segmentsread
  • GET/segments/:id/contactsread
  • GET/sequencesread
  • GET/sequences/:idread
  • GET/automationsread
  • GET/automations/:id/runsread
  • POST/automations/:id/startwrite
  • POST/conversionswrite
  • GET/conversionsread
GET/contactsscope: read

List contacts in a stable order, optionally only those with a tag or one exact person.

tag
Only contacts carrying this tag (exact name).
igsid
Contacts with this Instagram-scoped id — one per connected account, since the id is only unique within an account.
limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{
  "data": [ { "id": "ck…", "username": "maya.jewels", "tags": ["vip"], "canMessage": true, … } ],
  "nextCursor": "ck…"   // null on the last page
}
GET/contacts/:idscope: read

One contact by id — the whole contact object.

Response
{ "data": { "id": "ck…", "username": "maya.jewels", "customFields": { "city": "Austin" }, "canMessage": true, … } }
  • The contact.created webhook hands you a contactId and nothing else; this is how you turn it into the record.
GET/contacts/:id/messagesscope: read

The conversation with one contact, newest first.

direction
in (from them) or out (from you, including automations and broadcasts).
limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{
  "data": [
    {
      "id": "msg…", "direction": "out", "kind": "dm", "text": "Your order has shipped 📦",
      "attachments": [], "status": "sent", "error": null, "externalId": "mid.…",
      "automationId": null, "createdAt": "2026-09-24T15:02:11.000Z"
    }
  ],
  "nextCursor": null
}
  • kind is dm, comment, comment_reply, story_reply or postback.
  • status is sent, mock (a simulated account, so nothing left MuChat) or failed — a failed send keeps the reason in error.
  • attachments lists the media on the message as { type, url }. The raw Instagram envelope is never returned.
  • automationId is set when an automation sent the message.
POST/contacts/:id/sendscope: write

Send one DM to a contact from your connected Instagram account — text, or a file by public link.

Request body
{ "text": "Your order has shipped 📦" }        // 1–1000 characters
{ "attachmentUrl": "https://cdn.you.com/receipt.pdf" }   // or one public https link
Response
{ "data": { "id": "msg…", "status": "sent", "kind": "text", "externalId": "…", "createdAt": "2026-09-24T15:02:11.000Z" } }
  • Send exactly one of text or attachmentUrl — both, or neither, is a 400.
  • Instagram only lets a business message someone within 24 hours of that person's last message. Outside it you get a 409 whose body also carries lastInboundAt and windowExpiresAt, so you can tell a closed window from a broken request. Check canMessage on the contact first.
  • MuChat stores no files: Instagram fetches attachmentUrl itself, so it has to be a public https link to an image, video, audio file or PDF (jpg, png, gif, mp4, mov, mp3, m4a, pdf…) — not Word, Excel or other documents. Links that only resolve on a private network are refused.
  • Every send is written into the conversation, including a failed one, so /contacts/:id/messages matches what the inbox shows.
  • Text sends are plain text; buttons, links and templates are sent by automations.
POST/contacts/:id/tagsscope: write

Add tags to a contact, or remove them.

Request body
{ "tags": ["vip", "wholesale"], "mode": "add" }   // mode: "add" (default) or "remove"
Response
{ "data": { "id": "ck…", "tags": ["vip", "wholesale"], … } }
  • 1–30 tags per request, each up to 60 characters.
  • A contact keeps at most 30 tags; extra ones are dropped when adding.
POST/contacts/:id/fieldsscope: write

Set or clear a contact’s custom fields.

Request body
{ "fields": { "city": "Austin", "plan": "Pro", "churn_reason": null } }
Response
{ "data": { "id": "ck…", "customFields": { "city": "Austin", "plan": "Pro" }, … } }
  • Merges: keys you don't send are left alone. null (or "") clears a key. 1–20 keys per request, values up to 500 characters.
  • Only keys you have declared under Contacts → Fields are accepted — GET /fields lists them. An unknown key rejects the whole request with a 400 naming it, rather than half-applying the write.
  • Numbers and booleans are stored as strings, the way the app stores them, so a value written here compares equal to one typed into MuChat in a segment rule.
GET/fieldsscope: read

The custom fields this account has declared — the keys you may write.

Response
{ "data": [ { "key": "city", "name": "City", "type": "text", "createdAt": "…" } ] }
  • type is text, number, date or boolean. It describes what the field is for; values are stored and returned as strings.
  • Not paged — the list is small and hand-made.
GET/tagsscope: read

The account’s tag list, alphabetically.

Response
{ "data": [ { "name": "vip", "color": "#0e9f8e", "createdAt": "…" } ] }
  • This is the curated list from Settings → Automation → Tags. A contact can still carry a tag that was never added to it (an import, or a new name from POST /contacts/:id/tags), so read contact.tags for what a contact actually has.
  • Tags are addressed by name everywhere in the API, so no id is returned.
GET/segmentsscope: read

Your saved segments and the rules behind each one.

Response
{
  "data": [
    { "id": "sg…", "name": "VIPs who went quiet", "match": "all",
      "rules": [ { "field": "tag", "op": "has", "value": "vip" }, { "field": "last_interaction", "op": "before", "value": 30 } ],
      "createdAt": "…" }
  ]
}
  • Not paged.
  • A segment is a live filter, not a stored list — the rules tell you what the next endpoint is about to return.
GET/segments/:id/contactsscope: read

The contacts matching a segment right now.

limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{ "data": [ { "id": "ck…", "username": "maya.jewels", … } ], "nextCursor": "ck…" }
  • Evaluated on every call, so someone who stopped matching yesterday isn’t here today.
GET/sequencesscope: read

Your drip sequences, with how many steps and how many people have been enrolled.

limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{
  "data": [
    { "id": "sq…", "name": "Welcome series", "status": "LIVE", "steps": 3, "enrolled": 412, "createdAt": "…" }
  ],
  "nextCursor": null
}
  • Only a LIVE sequence sends anything.
  • This is what resolves the sequenceId a conversion.recorded webhook carries.
GET/sequences/:idscope: read

One sequence.

Response
{ "data": { "id": "sq…", "name": "Welcome series", "status": "LIVE", "steps": 3, "enrolled": 412, "createdAt": "…" } }
  • 404 when it belongs to another workspace or has been deleted — the two are indistinguishable on purpose.
GET/automationsscope: read

List your automations, most recently updated first.

status
DRAFT, LIVE or STOPPED.
limit
1–100, default 50.
Response
{ "data": [ { "id": "ck…", "name": "Auto-DM links from comments", "status": "LIVE", "builder": "flow", "triggerType": "COMMENT", "triggerTypes": ["COMMENT", "DM_KEYWORD"], "publishedAt": "…", … } ] }
  • This one is a plain list with no cursor — it returns at most limit automations and no nextCursor.
  • triggerTypes is every trigger that can start it right now; triggerType is the first of them, and null when every trigger is switched off.
GET/automations/:id/runsscope: read

The runs of one automation, newest first.

contactId
Only runs for this contact.
status
Only runs in this state, e.g. completed or waiting_reply.
limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{
  "data": [ { "id": "run…", "automationId": "ck…", "contactId": "ck…", "status": "completed", "createdAt": "…", "updatedAt": "…" } ],
  "nextCursor": null
}
  • The rows behind the automation.run webhook: it gives you a runId, and this is where you look it up or check later whether it finished.
  • Whatever a contact typed into an Ask step stays inside MuChat and is not returned here.
POST/automations/:id/startscope: write

Run a live automation for one contact — when a purchase, a form or a booking elsewhere should start a conversation here.

contactId
Required. The contact to run it for.
idempotencyKey
Optional, up to 120 characters. Also accepted as the Idempotency-Key header. The same key for the same contact on the same automation is one run, however many times the request arrives — send one whenever you might retry. A key is scoped to its contact, so reusing one for a different person starts their run rather than being mistaken for a retry.
Response
{ "data": { "runId": "run…", "duplicate": false } }
  • 201 when it started, 200 with duplicate: true when that idempotency key already ran.
  • 409 with a reason when it can't run right now: not_live, window_closed (Instagram only allows a message within 24 hours of their last one), paused (someone on your team took the conversation over), unsubscribed, or wrong_channel. The message says what to do.
  • 404 when the automation or the contact isn't in your workspace — the two read the same on purpose.
  • It runs through exactly the same path a keyword match does, so everything the automation does — its steps, its tags, its follow-ups — happens as if they had triggered it.
POST/conversionsscope: write

Record a conversion — a purchase, booking or sign-up — for one of your conversion events.

Request body
{ "key": "purchase", "contactId": "ck…", "value": 1999 }   // value in minor units: 1999 = $19.99
Response
{ "data": { "id": "cv…", "eventKey": "purchase", "contactId": "ck…", "automationId": "ck…", "value": 1999, "currency": "USD", "source": "api", … } }   // 201
  • key is the event’s key from Settings → Automation → Conversion events. An unknown key is a 404; an archived event is a 410.
  • Identify the person with contactId, or igsid if you only know their Instagram-scoped id. Both are optional.
  • value is a whole number of minor units in the event’s currency; leave it out to use the event’s default. Decimals and negatives are rejected.
  • Pass automationId to credit an automation yourself. Otherwise the conversion is credited to the last automation run or sequence that touched the contact in the 7 days before.
GET/conversionsscope: read

List recorded conversions, newest first.

key
Only this conversion event.
contactId
Only this contact.
limit
1–100, default 25.
cursor
The nextCursor from the previous page.
Response
{ "data": [ { "id": "cv…", "eventKey": "purchase", "value": 1999, "currency": "USD", … } ], "nextCursor": null }
Webhooks

Let MuChat call you

Add an endpoint under Settings → Webhooks and MuChat POSTs a signed JSON event the moment something happens — ready for your own server, Zapier, Make or anything else that accepts a webhook.

contact.created
The first time someone DMs, comments or taps a button — before any automation runs.
data: contactId, igsid, username, name, channelId, tags
read it back: GET /contacts/:id
contact.tagged
A tag is added to someone, however it arrived — a flow, the inbox, a bulk action or the API. Removing a tag sends nothing.
data: contactId, igsid, username, added, tags
read it back: GET /contacts/:id
conversion.recorded
A conversion is recorded — from the API, a flow step or a contact’s profile.
data: conversionId, eventKey, eventName, value, currency, contactId, automationId, sequenceId, source
read it back: GET /conversions?contactId=…
automation.run
An automation starts for a contact.
data: runId, automationId, automationName, status, contactId, trigger
read it back: GET /automations/:id/runs
inbox.message
Every inbound message that lands in the inbox, including paused conversations.
data: messageId, contactId, igsid, username, direction, kind, text
read it back: GET /contacts/:id/messages
  • Up to 10 endpoints per workspace, each subscribed to the events you pick. A Send test button checks your endpoint.
  • HTTPS only. Private, loopback and internal addresses are refused, and redirects aren’t followed.
  • Answer with any 2xx within 10 seconds. Failed deliveries are retried up to 5 times in all, backing off from 1 minute to 2 hours.
  • After 10 failed attempts in a row the endpoint is switched off; the delivery log shows what went wrong.
  • Every event names a record you can fetch with a read key, including a conversion’s sequenceId. The one exception is channelId on contact.created: the API neither returns nor accepts it anywhere, so treat it as an opaque marker of which Instagram account the contact arrived on.
Every delivery
POST https://your-endpoint.example
X-MuChat-Event: conversion.recorded
X-MuChat-Delivery: <delivery id>
X-MuChat-Version: 1
X-MuChat-Signature: sha256=<hex HMAC of the raw body>

{
  "id": "…",
  "event": "conversion.recorded",
  "version": 1,
  "createdAt": "2026-09-24T15:02:11.000Z",
  "workspaceId": "…",
  "data": { "eventKey": "purchase", "value": 1999, "currency": "USD", … }
}
Verify the signature (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";

// secret: the whsec_… signing secret, shown once when you create the endpoint
function isFromMuChat(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return header?.length === expected.length &&
    timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

The contact object

Only these fields are ever returned — new internal columns stay private by default.

id
MuChat contact id — use it in the other endpoints.
igsid
The person’s Instagram-scoped id for your account.
channelId
Which of your connected Instagram accounts they wrote to. Replies go out from that account.
username / name
Instagram username and display name, when known.
profilePicUrl
Profile picture URL, when known.
tags
Tag names on the contact.
customFields
Your custom field values, keyed by field key.
subscribed
The contact’s subscribed flag.
source
Where they came from: comment, DM, story, import or manual.
assignedToId / inboxStatus
Who the conversation is assigned to, and whether it’s open or closed.
lastInteractionAt / lastInboundAt
Last activity, and when they last messaged you.
canMessage
true while you’re inside Instagram’s 24-hour window.
createdAt
When they became a contact.

Errors and limits

Every failure comes back the same way: { "error": "a sentence you can show a human" } with one of these status codes. Some add a key or two alongside error — never instead of it.

400
Bad query parameters or JSON body — the message says what’s wrong, including which custom field key or attachment link was refused.
401
Missing, malformed, invalid or revoked API key.
403
The key doesn’t have the scope this endpoint needs.
404
No contact, message, segment, automation or conversion event with that id or key in your account. A record in somebody else’s account is a 404 too.
409
Can’t send: outside the 24-hour window (the body also carries lastInboundAt and windowExpiresAt), or no Instagram account connected.
410
That conversion event is archived and no longer records conversions.
429
Rate limit reached. Wait for Retry-After seconds.
500
Something went wrong on our side.
502
Instagram refused the message. The response includes the messageId we logged.

Each key can make at least 120 requests a minute, counted in a fixed window. The count is kept per server, so the true ceiling can be higher and resets when we deploy — don’t build on the exact number. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp); a 429 also carries Retry-After in seconds. Back off when Remaining reaches zero rather than retrying immediately — and treat 120/minute as the figure to design against, not a ceiling to aim at.

Get a key and start building

Create a free account, connect Instagram, and make your first call from Settings → API.