# IG DMer API

IG DMer automates Instagram comment→DM flows and AI DM replies, publishes reels/photos/carousels, and stores Instagram
insights. This API lets agents **read** all of it and, with a full-access key, **do** everything the dashboard does:
post and schedule content, DM people, build automations.

- **Base URL:** `https://igdmer.thejonbroadbent.com`
- **Auth:** every request needs `Authorization: Bearer <API_KEY>` (keys start with `igdm_`; the owner creates them on the
  Accounts page). `X-API-Key: <API_KEY>` also works. `GET /api/me` tells you your key's name and `scope`.
- **Scopes:** `read` keys can only `GET` (anything else → `403`). `write` (full access) keys can call every endpoint below,
  except key and team management, the webhook log, the dev simulator and disconnecting an account. A key belongs to one workspace and only sees that workspace's Instagram accounts (anything else answers 404).
- **Format:** JSON in and out (except uploads and `contacts.csv`). Timestamps are **unix milliseconds**. Days are `YYYY-MM-DD` (UTC).
- **Errors:** `{"error": "<message>"}` with status `400` (bad input; validation errors also carry `issues[]`), `401`
  (missing/invalid key), `403` (not allowed for this key), `404`, `502` (Instagram refused; has `code`).
- **Freshness:** comments/DMs are live (seconds). Instagram metrics sync daily (posts + account) and every 30 min (stories);
  Instagram itself can lag up to 48 h.

```bash
curl -H "Authorization: Bearer $IGDM_API_KEY" https://igdmer.thejonbroadbent.com/api/accounts
```

## Post a reel

```bash
BASE=https://igdmer.thejonbroadbent.com
AUTH="Authorization: Bearer $IGDM_API_KEY"

# 1. Account id
curl -s -H "$AUTH" $BASE/api/accounts            # → [{ "id": "…", "username": "the_truth_of_power", … }]

# 2a. Post a file on your disk (upload, then post)
KEY=$(curl -s -H "$AUTH" -H "Content-Type: video/mp4" --data-binary @reel.mp4 $BASE/api/uploads/direct | jq -r .key)
curl -s -H "$AUTH" -H "Content-Type: application/json" -H "Idempotency-Key: reel-2026-11-02-a" $BASE/api/posts \
  -d "{\"account_id\":\"$ACCOUNT\",\"r2_key\":\"$KEY\",\"caption\":\"Caption #tag\"}"

# 2b. …or a file that's already online (direct download link)
curl -s -H "$AUTH" -H "Content-Type: application/json" $BASE/api/posts \
  -d '{"account_id":"…","media_url":"https://example.com/reel.mp4","caption":"…","scheduled_at":1798000000000}'
# → 201 { "id": "<post id>", "kind": "reel", "status": "queued" | "scheduled", "scheduled_at": null | ms }

# 3. Poll until published (reels take 1–5 min for Instagram to process)
curl -s -H "$AUTH" $BASE/api/posts/<post id>     # → status "published" + media_id + permalink, or "failed" + error
```

## Typical agent workflows

- **Publish:** `GET /api/accounts` → optional `GET /api/posts/best-hours` → upload → `POST /api/posts` (with `scheduled_at`) →
  poll `GET /api/posts/:id`.
- **Comment → DM on a new reel:** pass `keywords` + `template_automation_id` (an existing automation whose DM to reuse) on
  `POST /api/posts`; the keyword automation is created for that reel when it goes live.
- **Performance:** `GET /api/analytics`, `GET /api/account-insights`, `GET /api/content` (sort by `metrics.views`, `rates.engagement_rate`…).
- **Inbox:** `GET /api/needs-you` → `GET /api/contacts/:id/chat` → `POST /api/contacts/:id/send`.
- **Live activity:** poll `GET /api/events?account_id=…&after_id=<last id seen>&limit=1000`.

---

## Write endpoints (full-access keys)

### Uploads

Files for posts are stored first, then referenced by `key` (`up-<uuid>.mp4|mov|jpg`). Unused uploads are deleted after 80 days;
uploads of published posts are deleted once Instagram has them.

**POST /api/uploads/direct** — the raw file as the request body, up to 100 MB (Cloudflare's request limit).
- Header `Content-Type`: `video/mp4`, `video/quicktime`, `image/jpeg` or `image/png`. Or pass `?name=file.mp4` / `?type=video|image`.
- Photos: JPEG or PNG (PNG is converted to JPEG on a white background, max 1440 px wide). Aspect must be 4:5 to 1.91:1 or you get `400`.
- → `201 { key, type: "video"|"image", size, width?, height? }`

**POST /api/uploads/from-url** `{ url, type?: "video"|"image" }` — the server downloads a public file (up to 1 GB). Must be a
direct file link (a web page returns `400`). Same response as above. You can skip this and pass `media_url` to `POST /api/posts`.

**Big files from disk (over 100 MB):** multipart in 20 MB parts (all parts except the last exactly 20 MB):
1. `POST /api/uploads` `{ name, type: "video/mp4", size }` → `{ key, upload_id }`
2. `PUT /api/uploads/<key>/<part 1..n>?upload_id=…` with the raw bytes of each part → `{ part_number, etag }`
3. `POST /api/uploads/<key>/complete` `{ upload_id, parts: [{ part_number, etag }] }` → `{ key, size }`

### POST /api/posts
Publish or schedule a post. Header `Idempotency-Key: <any string>` is strongly recommended: retrying with the same key returns
the first post (`200 { id, status, duplicate: true }`) instead of posting twice.

| field | type | notes |
|---|---|---|
| `account_id` | string | required |
| `kind` | `reel` \| `image` \| `carousel` | optional: one video → reel, one photo → image, `items` → carousel |
| `r2_key` | string | an upload key (reel / image) |
| `media_url` | url | instead of `r2_key`: the server downloads it |
| `items` | `[{ key }]` or `[{ url }]`, 2–10 | carousel slides, photos and/or videos (`type` optional) |
| `caption` | string ≤ 2200 | max 30 hashtags |
| `scheduled_at` | ms \| null | null/past = post now. Up to 75 days ahead |
| `share_to_feed` | bool, default true | reels only |
| `thumb_offset_ms` | int | reels: cover frame |
| `trial_strategy` | `MANUAL` \| `SS_PERFORMANCE` \| null | reels: post as a trial reel (non-followers first). MANUAL = you graduate it in the app |
| `keywords` | string[] ≤ 10 | comment keywords that trigger a DM on this post |
| `template_automation_id` | string | required with `keywords`: whose DM flow to send |
| `file_name` | string | label only |

→ `201 { id, kind, status: "queued"|"scheduled", scheduled_at }`. Statuses: `scheduled` → `queued` → `processing` →
`published` | `failed` | `cancelled`.

Not possible through Instagram's API: stories (not built here), music from Instagram's library, stickers, collabs, editing
anything but the caption after posting.

### GET /api/posts/:id · GET /api/posts?account_id=
One post / the latest 50. Fields: `id`, `kind`, `status`, `caption`, `scheduled_at`, `published_at`, `media_id`, `permalink`,
`error`, `keywords`, `automation_id` (the keyword automation made for it), `trial_strategy`, `created_via` (`dashboard` | `api:<key name>`).

### PATCH /api/posts/:id
Only while `scheduled`/`queued`. Any of: `scheduled_at`, `post_now: true`, `caption`, `keywords`, `template_automation_id`,
`trial_strategy`, `share_to_feed`. `400` once publishing has started.

### DELETE /api/posts/:id
Cancel a scheduled/queued post (deletes its upload).

### GET /api/calendar?account_id=&from=&to=
What went out (`media[]`, with views) and what's planned (`posts[]`) in a time range (ms).

### GET /api/posts/best-hours?account_id=
Median reel views by hour posted (Europe/Madrid), hours with 2+ reels: `{ time_zone, reels, hours: [{ hour, reels, median_views }] }`.

### GET /api/accounts/:id/publishing
`{ allowed }`: false means the account must be reconnected in the dashboard with the publish permission.

---

### Automations

**POST /api/automations** → `201 { id }` · **PUT /api/automations/:id** (full replace, same body) · **PATCH /api/automations/:id**
`{ active }` · **DELETE /api/automations/:id** (analytics history is kept).

Body (comments are explanations, send plain JSON):
```jsonc
{
  "account_id": "…",
  "name": "BOOK → link",
  "trigger_type": "comment",            // comment | dm_keyword | story_reply | story_mention | live_comment
  "media_scope": "any",                 // any | specific (then media_ids: [Instagram media ids])
  "media_ids": [],
  "keywords": ["book"],
  "match_mode": "contains",             // contains (whole word) | exact | any
  "exclude_keywords": [],
  "public_replies": ["Sent you a DM 👀"],  // public reply under the comment, one picked at random
  "once_per_user": true,
  "priority": 0,                        // higher wins when several match
  "active": true,
  "flow": {
    "start": "s1",
    "steps": {
      "s1": { "type": "message", "text": "Here you go 👇", "buttons": [{ "type": "url", "title": "Open", "url": "https://…" }] }
    }
  }
}
```
Step types: `message` (`text` ≤1000, `variants[]`, `image_url`, `buttons[]` ≤3: `{type:"url",title≤20,url}` or
`{type:"postback",title,next}`, `quick_replies[]` `{title,next}`, `next`, `track_links`), `follow_gate` (`pass`, `fail`?, `fail_text`,
`retry_button`), `collect_email` (`prompt`, `invalid_text`, `next`), `ai_reply` (`brief`, `link`, `max_chars`, `daily_limit`,
`hard_limit`, `react_to_acks`, `handoff_text`, `extra_links[]`, `model`, `next`), `tag` (`add[]`, `next`), `delay` (`seconds` ≤ 43200, `next`).
Every `next`/`pass`/`fail` must point at an existing step id. Instagram rules the engine enforces: a comment allows exactly one
private reply; follow-ups only after the person replies or taps a button (24 h window).

**POST /api/automations/:id/backfill** `{ dry: true, spacing_min: 60, spacing_max: 90, since?, until? }` — DM people who commented a
matching keyword in the last 7 days before the automation existed. Always call with `dry: true` first (returns counts), then
`dry: false`. **DELETE /api/automations/:id/backfill** stops queued backfill DMs.

**POST /api/accounts/:id/copy-automations** `{ from_account_id, ids[] }` — copy automations from another account (switched off).

### DMs and contacts

**POST /api/contacts/:id/send** `{ text ≤ 1000 }` — DM a contact. Only inside Instagram's 24 h window (the person messaged in
the last 24 h; `GET /api/contacts/:id/chat` returns `window_ends_at`), else `400`. Counts as a human reply: the AI pauses 12 h for
this person. Logged as `human_reply` with `meta.via = "api:<key name>"`.

**POST /api/contacts/:id/send-voice** `{ clip_id }` — send a voice note (same rules).

**GET /api/contacts/:id/chat** — last 16 messages both ways + `window_ends_at`.

**GET /api/needs-you?account_id=** — chats the AI handed off. **POST /api/needs-you/:contactId/done** clears one.
**POST /api/contacts/:id/ai-catchup** — let the AI answer a stranded chat now (inside the window).

### Voice notes
- **POST /api/voice/clips/tts** `{ account_id, text ≤ 1500, voice_id? }` — ElevenLabs text-to-speech with the account's connected key → clip `{ id, url, … }`
- **POST /api/voice/clips/upload?account_id=&name=** — raw audio body (`audio/mp4`/m4a, `audio/aac`, `audio/wav`; not mp3), ≤ 25 MB
- **GET /api/voice/clips?account_id=** (saved library) · **PATCH /api/voice/clips/:id** `{ name }` (save to library) · **DELETE /api/voice/clips/:id**

### Accounts and settings
- **PATCH /api/accounts/:id** `{ bot_disclosure?, ai_persona?, ai_hidden_names?[], dm_cap_per_hour? }` (dm_cap_per_hour 1–300 or null = 300; GET /api/accounts also returns dm_cap_effective and dms_last_hour)
- **POST /api/accounts/:id/sync** — refresh all Instagram insights now
- **POST /api/accounts/:id/subscribe** — re-subscribe Instagram webhooks
- **GET /api/accounts/:id/media** — the account's posts (id, caption, permalink, type, posted_at): use for `media_ids`
- **GET|PUT /api/accounts/:id/links** `{ track_links?, link_domain? }` — tracked-link settings; **POST …/links/check** re-tests the domain
- **GET|PUT|DELETE /api/accounts/:id/elevenlabs** `{ api_key?, voice_id?, voice_name?, model? }`; **POST …/elevenlabs/options** lists voices/models

### Retention analysis
- **POST /api/content/:mediaId/uploads** — raw PNG/JPEG/WebP screenshot of the reel's retention graph (body, `Content-Type` set). Analysis runs in the background.
- **GET /api/content/:mediaId/uploads** — uploads with `analysis` (JSON: summary, fixes, holds, cliffs). **POST /api/uploads/:id/analyse** re-runs it.
- **GET /api/content/:mediaId/transcript** — `{ text, words: [[start_s, end_s, word]], duration_s }`

---

## Read endpoints (any key)

### GET /api/accounts
Connected Instagram accounts.

Fields: `id` (use as `account_id`), `ig_user_id`, `username`, `name`, `profile_pic_url`, `status` (`active` | `token_expired` |
`disconnected`), `last_error`, `insights_synced_at`, `insights_error`, `token_expires_at`, `created_at`.

### GET /api/automations?account_id=
Automations (comment/DM triggers) with 30-day stats.

Fields: `id`, `name`, `trigger_type` (`comment` | `dm_keyword` | `story_reply` | `story_mention` | `live_comment`),
`media_scope` (`any` | `specific`), `media_ids[]`, `keywords[]`, `match_mode` (`contains` whole-word | `exact` | `any`),
`exclude_keywords[]`, `public_replies[]` (public comment replies, one picked at random), `once_per_user`, `priority`, `active`,
`triggered_30d`, `dms_30d`, `clicks_30d`, `created_at`, `updated_at`.

### GET /api/automations/:id
One automation plus its `flow`: `{ start: stepId, steps: { [stepId]: Step } }`. Step types:
- `message`: `text`, `variants[]` (random alternative wordings), `buttons[]` (`url` or `postback`→`next`), `quick_replies[]`, `next`, `track_links`
- `follow_gate`: checks if the person follows; `pass` / `fail` step ids
- `ai_reply`: LLM reply; `brief`, `link`, `max_chars`, `daily_limit`, `model`
- `collect_email`, `tag` (`add[]`), `delay` (`seconds`, `next`)

### GET /api/analytics?account_id=&automation_id=&days=30
DM funnel. `days` 1–365. `automation_id` optional.

- `totals` / `unique` (people): `comment_received`, `trigger_matched`, `dm_sent`, `dm_read`, `postback` (button taps),
  `link_click` (tracked links only), `follow_check_pass`, `email_captured`
- `new_contacts`
- `daily[]`: `{ day, …same keys }`
- `automations[]`: per automation `{ automation_id, name, trigger_matched(_uniq), dm_sent(_uniq), dm_read(_uniq) }`

### GET /api/content?account_id=&type=all&days=0
Every post/reel/story with Instagram metrics and the DMs it drove. `type`: `all` | `FEED` | `REELS` | `STORY`.
`days`: only posts from the last N days (`0` = all time).

Per item:
- `id` (Instagram media id), `media_type`, `media_product_type` (`FEED` | `REELS` | `STORY`), `caption`, `permalink`,
  `thumbnail_url`, `media_url`, `posted_at`, `metrics_at`, `insights_error`
- `metrics` (lifetime totals from Instagram; keys present depend on type): `views`, `reach`, `likes`, `comments`, `shares`,
  `saved`, `total_interactions`, `follows`*, `profile_visits`*, `ig_reels_avg_watch_time` (ms), `ig_reels_video_view_total_time` (ms),
  `reels_skip_rate`, posts/stories also `profile_activity` + `pa_bio_link_clicked`, `pa_email`, `pa_call`, `pa_direction`, `pa_text`, `pa_other`,
  stories also `replies`, `link_clicks`, `nav_*`. *Instagram does not report follows/profile visits for reels.
- `rates` (0–1): `like_rate` (likes/views), `comment_rate`, `share_rate`, `save_rate`, `engagement_rate` (interactions/reach),
  `follow_rate`, `profile_visit_rate` (null when not available)
- `dm`: `dm_triggered` (people who commented a keyword), `dm_sent`, `dm_tapped`, `dm_clicked`, `dm_emails`, `dm_follow_ok`, `comment_webhooks`

### GET /api/content/:mediaId
Same as one content item plus `snapshots[]`: one row per day `{ day, …metrics }` (growth curve of the lifetime totals).

### GET /api/account-insights?account_id=&days=30
Account-level growth. `days` 1–90 (Instagram keeps 90 days).

- `daily[]`: `{ day, reach, views, accounts_engaged, total_interactions, likes, comments, shares, saves, replies, reposts,
  follows, unfollows, net_follows, profile_views, website_clicks (bio link taps), profile_links_taps (contact buttons), followers_count?,
  views_follow_follower, views_follow_non_follower, reach_follow_follower, reach_follow_non_follower,
  views_type_reel|post|story, reach_type_*, total_interactions_type_*, likes_type_*, comments_type_*, shares_type_*, saves_type_* }`
- `totals`: sums of the main keys plus `followers_now`, `followers_change`
- `link_taps_by_type`: bio link / button taps by type
- `audience[]`: `{ kind (follower_demographics | engaged_audience_demographics), breakdown (age | gender | country | city), data, captured_at }`,
  plus `{ kind: "online_followers", breakdown: "hour_pt", data: { "0".."23": followers online } }` (hours in US Pacific time)

### GET /api/contacts?account_id=&q=&limit=50&offset=0
People who commented on a trigger or messaged. `limit` ≤ 200. `q` searches username/email/tags.
Response: `{ total, contacts: [{ id, igsid, username, name, follows_business (1/0/null), email, tags (JSON array string),
last_inbound_at, first_seen_at, updated_at }] }`

### GET /api/contacts.csv?account_id=
Same contacts as CSV.

### GET /api/events?account_id=&type=&after_id=&since=&limit=50
Raw activity log. `limit` ≤ 1000. Default order newest first. With `after_id` the order is **oldest first after that id**:
use it to sync incrementally. `since` = unix ms lower bound. `type` filters one event type.

Fields: `id` (increasing), `ts`, `type`, `account_id`, `automation_id`, `automation_name`, `contact_id`, `username`, `igsid`,
`media_id`, `ref`, `meta` (JSON string).

Event types:
| type | meaning | useful `meta` |
|---|---|---|
| `comment_received` | someone commented | `text` |
| `trigger_matched` | comment/DM matched an automation | `text` |
| `public_reply_sent` | public reply posted under the comment | `reply_id` |
| `dm_sent` | we sent a DM | `step`, `kind` (buttons/text/ai/fallback_text), `text`, `private_reply`, `variant` |
| `dm_read` | they read it | |
| `postback` | they tapped a button | `step` |
| `link_click` | tracked link clicked | `url` |
| `follow_check_pass` / `follow_check_fail` | follow gate result | |
| `message_received` | they sent us a DM | `text` |
| `human_reply` | the owner replied by hand (AI pauses 12 h for that person) | |
| `ai_skipped` | AI didn't reply | `reason` |
| `post_published` / `post_failed` | a post made here went live / failed | `permalink` / `error` (`ref` = post id) |
| `email_captured`, `trigger_skipped_repeat`, `postback_ignored`, `reply_debounced`, `backfill_queued` | | |

---

## Notes for agents
- Posting is real: it goes live on Instagram. Use `scheduled_at` if a human should get a chance to review it in the dashboard
  (Post page → calendar), and always send an `Idempotency-Key`.
- Instagram caps DMs at ~300/hour per account; sends over the cap are queued, not lost. Comment triggers stop at 200/hour.
- All numbers are as reported by Instagram. Instagram gives no per-reel follows/profile visits; use account-level `follows` and `profile_views`.
- `contacts.tags` and `events.meta` are JSON-encoded strings: parse them.
- Be polite: poll `events` at most every 15 s; other endpoints change at most every 30 min.
