# Comment-to-DM Automations API: Instagram keyword DMs

import { TechArticle } from "../components/TechArticle";

<TechArticle slug="automations" fm={frontmatter} />

A comment-to-DM automation answers a keyword with a private message. When somebody comments "guide" on your Instagram post, the account sends them the guide in a DM, once per person, and can post a public reply under the comment ("Sent you a DM!"). The same automation can listen to a keyword in a DM to the account or in a reply to a story.

Automations are managed with the [Automations endpoints](/api/automations) (`GET/POST /automations`, `GET/PATCH/DELETE /automations/:id`, `GET /automations/:id/events`), or attached to a new post with `comment_dm_automation` on [`POST /posts/create`](/creating-posts#comment-to-dm-automation). Everything you create here shows up on the Automations page in the dashboard, and the other way round.

## Enabling automation access

The scopes are opt-in, like the inbox scopes: an automation sends DMs in your account's name, and its activity carries what people wrote. When you create an API key, turn on **Automations** to grant `automations:read` (list, get, activity) and `automations:write` (create, update, delete, and `comment_dm_automation` on posts). Existing keys are not given these scopes automatically; without them every endpoint answers `403 insufficient_scope`.

## Creating an automation

```json
POST /automations
{
  "name": "Free guide",
  "post_id": "83778183",
  "trigger": { "keywords": ["guide", "link"], "match": "word" },
  "actions": {
    "dm": { "text": "Here is the guide: https://example.com/guide" },
    "public_reply": { "enabled": true, "variations": ["Sent you a DM!", "Check your inbox"] }
  },
  "limits": { "max_sends": 500 }
}
```

| Field | Meaning |
| --- | --- |
| `trigger.kinds` | What starts the automation: `comment` (default) = a comment on a post or reel, `dm` = a keyword in a DM to the account, `story_reply` = a keyword in a reply to a story. One automation can listen to several. |
| `trigger.scope` | For the `comment` kind: `post` (default) = one post, `next_post` = the next Instagram post the workspace publishes, `any` = every post of the account. |
| `post_id` | For scope `post`: an OmniSocials post that goes to Instagram, scheduled or already live. A scheduled post starts listening once it is live. One automation per post. |
| `instagram_media_id` | For scope `post`: the numeric id of a post or reel that was published outside OmniSocials (from Instagram's API, `GET /me/media`). OmniSocials reads it from Instagram to check it belongs to the connected account. Pass `post_id` or `instagram_media_id`, not both. |
| `trigger.keywords` | Up to 20 keywords, matched case-insensitively. Required unless `match` is `any`. |
| `trigger.match` | `word` (default): the keyword stands on its own ("guide", "Guide!" match; "linkedin" does not match "link"). `contains`: the keyword appears anywhere. `any`: every comment or message with text. |
| `actions.dm.text` | The DM, max 1000 characters. Sent once per person per automation. |
| `actions.public_reply` | A public reply under the comment, for the `comment` kind only. One of `variations` (up to 10) is picked at random per comment. |
| `limits.max_sends` | Stop after this many DMs; the automation becomes `completed`. |
| `limits.stop_at` | Stop at this time (ISO 8601). |
| `status` | `active` (default) or `paused` to create it without turning it on. |

The response is the automation with `instagram_media_id` (set once the post is live), a `post` snapshot (`source` is `omnisocials` or `instagram`, with `caption`, `thumbnail_url`, `permalink`) and `stats` (`matched`, `sent`, `skipped`, `failed`, `last_event_at`).

A keyword in any DM or story reply, no post needed:

```json
POST /automations
{
  "trigger": { "kinds": ["dm", "story_reply"], "keywords": ["menu"] },
  "actions": { "dm": { "text": "Our menu: https://example.com/menu" } }
}
```

## Instagram rules

- One private reply per comment, and only within 7 days of the comment. A comment older than 7 days is skipped (`window_passed`).
- A follow-up message is possible only after the person answers (Meta's 24-hour window).
- The DM goes to each person once per automation (`already_sent`).
- OmniSocials sends at most 180 DMs an hour per account (`rate_limited`); Instagram's own limit for private replies is 750 an hour.
- Public replies belong to comments; a DM or story-reply trigger has none.

## Updating, pausing, deleting

`PATCH /automations/:id` takes the same fields. `status: "paused"` pauses, `"active"` resumes. `trigger`, `actions` and `limits` are merged into the stored sections: keys you omit keep their value, so `{ "trigger": { "keywords": ["pdf"] } }` keeps the kinds, the scope and the match, and a new `dm.text` keeps the public reply. `public_reply.enabled: false` (or `variations: []`) turns the public reply off; `limits.max_sends: null` removes that limit. Another `post_id` or `instagram_media_id` moves the automation to that post. A `completed` automation starts again when its limits or trigger change. `DELETE /automations/:id` removes it and its activity; DMs already sent stay sent.

## Activity

`GET /automations/:id/events` returns one row per comment or message the automation acted on, newest first, with `counts` per outcome and offset pagination:

```json
{
  "data": [{
    "id": "1", "kind": "comment", "outcome": "sent",
    "person": { "id": "1784...", "username": "jane" },
    "trigger_text": "guide please",
    "message": "Here is the guide: https://example.com/guide",
    "public_reply": "Sent you a DM!",
    "sent_at": "2026-10-10T21:40:12.000Z"
  }],
  "counts": { "sent": 12, "skipped": 3 },
  "pagination": { "total": 15, "limit": 25, "offset": 0, "has_more": false }
}
```

`outcome` is `sent`, `skipped` (with `reason`: `already_sent`, `limit_reached`, `stopped`, `window_passed`, `rate_limited`, and `reason_label` in plain words), `failed` (with Instagram's error in `reason`) or `pending`. Filter with `?outcome=`. The text of a DM or story reply is private: `trigger_text` on `dm` and `story_reply` rows is returned only to a key with the `inbox:read` scope; a comment is public and always returned.

## On a new post

Pass `comment_dm_automation` on `POST /posts/create` to bind an automation to the post you are creating (Instagram must be in `accounts`). It is the create body without `post_id`, `scope` and `kinds`: always this post, comment trigger. The created automation comes back as `comment_dm_automation` on the post, `GET /posts/:id` returns it, and `PATCH /posts/:id` replaces it with a new object or removes it with `null`. See [Creating posts](/creating-posts#comment-to-dm-automation).

## Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `validation_error` | Bad input; the message says what (for example "Write the DM", "Add at least one keyword, or choose any comment") |
| 400 | `post_not_instagram` | The post does not go to Instagram |
| 400 | `post_required` | Scope `post` without `post_id` or `instagram_media_id` |
| 403 | `insufficient_scope` | The key has no `automations:read` / `automations:write` |
| 404 | `post_not_found`, `instagram_media_not_found`, `not_found` | No such post, Instagram media or automation in this workspace |
| 409 | `instagram_not_connected`, `instagram_needs_reauth` | Connect or reconnect Instagram first |
| 409 | `automation_exists` | The post already has an automation; the response carries its `automation_id` |

## Using it from an AI assistant

The [MCP server](/mcp-server) has `list_automations`, `get_automation`, `create_automation`, `update_automation`, `delete_automation` and `get_automation_activity`, and `create_post` takes `comment_dm_automation`. The [CLI](/cli/management#automations-comment-to-dm) has the `automations:*` commands and the `--dm-*` flags on `posts:create`.
