> ## Documentation Index
> Fetch the complete documentation index at: https://www.ayrshare.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Automations API Overview

> Engagement-triggered Instagram automations — fire a DM, webhook, or email when an end user comments, replies to a story, reacts to a DM, or sends a DM

export const PlansAvailable = ({plans = [], maxPackRequired}) => {
  let displayPlans = plans;
  if (plans && plans.length === 1) {
    const lowerCasePlan = plans[0].toLowerCase();
    if (lowerCasePlan === "business") {
      displayPlans = ["Launch", "Business", "Enterprise"];
    } else if (lowerCasePlan === "premium") {
      displayPlans = ["Premium", "Launch", "Business", "Enterprise"];
    }
  }
  return <Note>
Available on {displayPlans.length === 1 ? "the " : ""}
{displayPlans.join(", ").replace(/\b\w/g, l => l.toUpperCase())}{" "}
{displayPlans.length > 1 ? "plans" : "plan"}.

{maxPackRequired && <span onClick={() => window.open('https://www.ayrshare.com/docs/additional/maxpack', '_self')} className="flex items-center mt-2 cursor-pointer">
 <span className="px-1.5 py-0.5 rounded text-sm" style={{
    backgroundColor: '#C264B6',
    color: 'white',
    fontSize: '12px'
  }}>
   Max Pack required
 </span>
</span>}
</Note>;
};

<PlansAvailable plans={["launch", "business", "enterprise"]} maxPackRequired={false} />

<Note>
  **Beta.** The Automations API is in beta and we are actively collecting feedback. Endpoints, payloads, and limits may change as we iterate. Please send feedback and bug reports to support so we can prioritize the right improvements.
</Note>

The Automations endpoints let you define rules that automatically react to incoming Instagram engagement. Each automation pairs one or more **triggers** (the event that fires the rule) with one or more **actions** (what happens when it fires). A single rule can listen for multiple triggers and dispatch multiple actions — fire a webhook into your analytics pipeline AND send a DM from the same engagement.

The engine only reacts to engagement a user directs at you (a comment, story reply, DM, or reaction) — it never sends follow-triggered DMs, first-message DMs to strangers, or bulk outbound — and it inherits Ayrshare's per-account rate limits, per-recipient deduplication, and idempotent webhook ingestion.

<Info>
  **`sent` means Meta accepted the message, not that the recipient received it.** Delivery is ultimately dictated by the recipient's Instagram **Message requests** setting: if they don't allow message requests from everyone, Meta returns a success response and silently drops the message, and this is invisible at every API layer. Even a delivered comment-triggered DM arrives as a **message request the recipient must accept** (unless a conversation already exists between the two accounts). See [Automation DM Sent but Not Delivered](/docs/help-center/technical-support/automation_dm_sent_but_not_delivered) for the full explanation.
</Info>

## How it works

<Steps>
  <Step title="Create an automation">
    `POST /automations` with the triggers and actions you want. The automation activates immediately.
  </Step>

  <Step title="An end user engages">
    Someone comments on your post, replies to your story, sends a DM, or reacts to a DM. Meta delivers the webhook to Ayrshare.
  </Step>

  <Step title="Ayrshare matches and dispatches">
    A 20–60 second jitter is applied to the engagement to stay inside Instagram's anti-spam heuristics. The engine then looks up every rule matching the event, checks per-action deduplication and your daily DM cap, and runs each action.
  </Step>

  <Step title="Inspect what fired">
    `GET /automations/:id/activity` returns the audit log — every dispatch attempt, the per-action results, and any errors.
  </Step>
</Steps>

<h2 id="triggers">
  Triggers
</h2>

You can attach up to **50 triggers** to one automation. Each trigger is a discriminated union on the `type` field; type-specific fields live at the same level. All triggers are Instagram-only in v1.

| Type | Fires when | Config |
| - | - | - |
| `comment_keyword` | A comment matching a keyword lands on a post or reel | `postId` (required — a post ID, or `"all"`); `keywords` (required, ≥1 entry) |
| `story_reply` | A user replies to a story via DM | `storyId` (optional — a story ID, or `"all"`) |
| `dm_reaction` | A user reacts to one of your DMs with an emoji | `emoji` (optional — omit to fire on any emoji) |
| `dm_keyword` | A user sends a DM whose text matches a keyword | `keywords` (required, ≥1 entry) |

Keyword matching is **case-insensitive** and whole-word. An event satisfies a keyword-filtered trigger if it contains any one of the configured keywords.

<h3 id="evergreen-rules-with-all">
  Evergreen rules with `"all"`
</h3>

Set `postId` (or `storyId`) to the literal lowercase string `"all"` to fire on **every** post, reel, or story on the connected account instead of one specific piece of content. This is what you want for a rule that should keep working as new content is published, with no API call per post:

```json theme={"system"}
{
  "type": "comment_keyword",
  "postId": "all",
  "keywords": ["LINK", "INFO"]
}
```

The value must be exactly `"all"`. A variant such as `"ALL"` or `" all"` is rejected with a validation error rather than saved as a post ID that would never match.

`"all"` never widens beyond the connected account — a rule on one profile cannot fire on another profile's content. Your daily DM cap and the engagement-anchored send rules apply exactly as they do to a single-post rule.

<Warning>
  Deduplication is counted per automation and recipient, not per post. On an `"all"` rule, a user who comments a keyword on two different posts within the dedup window (7 days by default) receives the DM only once; the second activity is recorded as `deduplicated`. A smaller [`dedupWindowMinutes`](#per-action-dedup-window) only shortens that window; it does not make deduplication per post. Setting it to `0` turns deduplication off, so every matching comment sends a DM, including repeat comments on the same post.
</Warning>

<Note>
  On the story triggers, omitting `storyId` behaves the same as `"all"`. Prefer `"all"` — it states the intent explicitly, and omission will be retired in a future release with advance notice on [Upcoming API Changes](/docs/whatsnew/upcoming-api-changes).
</Note>

<h2 id="actions">
  Actions
</h2>

You can attach up to **50 actions** to one automation. They run sequentially; each result is recorded on the activity row.

| Type | Effect | Config |
| - | - | - |
| `send_dm` | Sends an Instagram DM to the user who triggered the rule, using a [templated message](#template-variables) or [text you generate per fire](#dynamic-message-content). | `message` (required, templated); `messageUrl` (optional); `timeoutMs` (optional) |
| `fire_webhook` | POSTs the automation context to your account-level webhook URL (configure via [`POST /hook/webhook`](/docs/apis/webhooks/register)). | `sendCompletedEvent` (optional, boolean — also send [`automation.completed`](#automation-completed)). Payload shape is fixed; see [below](#fire_webhook-payload) |
| `send_email` | Queues an email via the platform's mail pipeline. | `to` (required, email); `subject` (optional, templated); `message` (required, templated) |

<h3 id="per-action-dedup-window">
  Per-action dedup window
</h3>

Every action — regardless of type — additionally accepts an optional top-level `dedupWindowMinutes` field that overrides the **default 7-day** per-recipient dedup window for that action only.

* Set to `0` to **disable** dedup entirely for that action (typical for `fire_webhook` / `send_email` where the receiver expects every event).
* Capped at `525600` (one year).

```json Action with a 24h dedup override theme={"system"}
{
  "type": "send_dm",
  "message": "Thanks {{recipient_username}}!",
  "dedupWindowMinutes": 1440
}
```

### Dynamic message content

By default, `send_dm` sends the same `message` to everyone who triggers the rule. Placeholders such as `{{recipient_username}}` are the only part that changes from person to person. Set **`messageUrl`** on the action and your own server writes the DM for each person instead: every time the rule matches, we ask your endpoint what to say, then send its answer as the DM.

**Example.** Your rule replies to anyone who comments "LINK" on a post, and `messageUrl` points at your server. When Ava comments "LINK please!":

1. We `POST` the details to your `messageUrl`: who commented (`"recipientUsername": "ava"`), what they wrote, and which post it was on.
2. Your server writes a reply for Ava, for example with a tracking link made just for her, and answers `{ "message": "Hi ava, here's your link: https://example.com/r/12345" }`.
3. We send that text to Ava as the DM, exactly as you wrote it.

If your server does not answer within `timeoutMs`, returns an error, or returns something we cannot use, we send Ava the action's regular `message` instead, so she still gets a reply.

Add it to a `send_dm` action like this:

```json send_dm with messageUrl theme={"system"}
{
  "type": "send_dm",
  "messageUrl": "https://yourapp.example.com/automations/message",
  "timeoutMs": 3000,
  "message": "Thanks for commenting! Here is the link: https://example.com"
}
```

`messageUrl` is where we ask for the text. `message` is still required and is the fallback: it is never sent to your server, and we DM it instead whenever your server does not answer in time, returns an error, or returns something we cannot use (see the warning below). `timeoutMs` is optional (default 3000, maximum 5000).

**What we send your endpoint.** A `POST` with this JSON body:

```json Callback request body theme={"system"}
{
  "automationId":      "auto_9xKp2Lm4nQ",
  "triggerId":         "trg_a1b2c3",
  "trigger":           "comment_keyword",
  "platform":          "instagram",
  "recipientId":       "17841401234567890",
  "recipientUsername": "ava",
  "keyword":           "LINK",
  "activityId":        "auto_9xKp2Lm4nQ:trg_a1b2c3:comment.received:17912345678901234",
  "actionId":          "act_dm_1",
  "eventId":           "comment.received:17912345678901234",
  "commentId":         "17912345678901234",
  "commentText":       "LINK please!",
  "postId":            "17998765432109876",
  "storyId":           null,
  "timestamp":         "2026-05-12T09:14:22.000Z",
  "timeStamp":         1778577262
}
```

| Field | Meaning |
| - | - |
| `automationId`, `triggerId`, `trigger`, `platform` | Which automation and trigger fired, as on the [`fire_webhook` payload](#fire_webhook-payload) |
| `recipientId`, `recipientUsername` | The person the DM goes to. `recipientUsername` is `null` when the trigger does not carry one (for example `dm_keyword`) |
| `keyword` | The keyword that matched, or `null` |
| `activityId` | The activity entry for this fire, so you can match it to [`GET /automations/:id/activity`](/docs/apis/automations/get-activity) |
| `actionId` | Which `send_dm` action is asking, matching `actionResults[].actionId`. Lets one endpoint serve several `send_dm` actions on the same automation |
| `eventId` | Our id for the engagement that caused the fire |
| `commentId` | The Instagram comment id for comment triggers; `null` for DM and story triggers |
| `commentText` | The text that matched: the comment, the DM, or the reaction emoji. `null` when there is none (for example `story_reply`) |
| `postId`, `storyId` | The post or story the engagement landed on, or `null` |
| `timestamp` | When we made the request, ISO 8601 |
| `timeStamp` | The same moment as a Unix time **in seconds**. Present only when the callback is signed, as on our webhooks |

If you have registered a [webhook secret](/docs/apis/webhooks/register), the body is signed with the same secret as your `automations` webhooks, in the same way: `X-Authorization-Content-SHA256` is the HMAC-SHA256 of the raw body, and `X-Authorization-Timestamp` carries the body's `timeStamp`. The verification code you already use for our webhooks works unchanged. If a secret is registered but we cannot read it at that moment, we do **not** send the callback unsigned; we send your `message` template instead.

**Where it can point.** `messageUrl` must be a public HTTPS address. A URL that resolves to a private, loopback, link-local or cloud-metadata address is refused, and redirects are not followed — a `3xx` answer counts as a failure. Either way the template is sent instead.

**What your endpoint sends back.** Return `200` with a JSON body, at most 16 KB, containing a `message` string of 1 to 1000 characters:

```json theme={"system"}
{ "message": "Hi ava — grabbed that link for you: https://example.com/r/12345" }
```

Your text is sent **verbatim**. `{{placeholder}}` substitution is not applied to it, so a literal `{{` in your generated text reaches the recipient as written.

<Warning>
  **`message` stays required, and it is not decoration.** If your endpoint times out, returns a non-2xx, returns a body we cannot use, or has been temporarily circuit-broken after repeated failures, we send the rendered `message` template instead. That send can still fail, for example if the template renders empty (see below) or Instagram rejects it. We never retry the callback — a retry risks sending the recipient two messages.
</Warning>

`timeoutMs` defaults to **3000**. The maximum is **5000**: a larger value is rejected with a validation error when you create or update the automation. It covers the whole exchange, including reading your response body. Budget for it: the DM is sent after your endpoint answers, so a slow endpoint delays the message.

Check which path a fire took on [`GET /automations/:id/activity`](/docs/apis/automations/get-activity). A `send_dm` entry in `actionResults[]` carries `messageSource` once the DM text has been worked out, including when the send then failed. It is absent when the action stopped before that, for example a `skipped` DM to your own account or a `deduplicated` action:

| `messageSource` | Meaning |
| - | - |
| `template` | No `messageUrl` configured; the static `message` was used |
| `callback` | Your endpoint answered and its text was used |
| `fallback` | `messageUrl` was set but unusable; `message` was used instead |

`messageSource` says which text the DM was built from, not whether it was delivered. Read the entry's `status` for that.

A fallback on its own does not fail the action: when the template DM is delivered, the entry is `sent`, with the reason the callback was not used on `errorDetails`. If the template DM then fails too, the entry is `failed` and `errorDetails` gives the send error followed by the callback reason. Watch for it: a callback failing on every fire otherwise looks exactly like one that is working.

Keep your `message` sendable on its own. If it renders empty for a recipient, for example `{{recipient_username}}` alone on a DM trigger that carries no username, and your callback text is not used, no DM is sent and the action is `failed` with the reason on `errorDetails`.

<h3 id="webhook-events">
  Webhook events
</h3>

Two events are delivered to the `automations` webhook action you register with [`POST /hook/webhook`](/docs/apis/webhooks/register). Both carry a **`type`** field — switch on it before reading anything else.

| `type` | Sent when |
| - | - |
| `automation.triggered` | The rule's `fire_webhook` action runs |
| `automation.completed` | The dispatch finished, with its outcome |

<Note>
  `automation.completed` is off by default. Turn it on for an automation by setting `"sendCompletedEvent": true` on its `fire_webhook` action. It stays off otherwise, so a receiver built for one payload per fire keeps getting exactly one. `automation.triggered` is not a record of every match: it is sent only when `fire_webhook` itself runs, so a match that ends `rate_limited` or `deduplicated` produces `automation.completed` alone. Neither event is sent for an automation that was deactivated or deleted before the dispatch ran.
</Note>

<h4 id="fire_webhook-payload">
  `automation.triggered`
</h4>

```json theme={"system"}
{
  "type":              "automation.triggered",
  "automationId":      "auto_9xKp2Lm4nQ",
  "triggerId":         "trg_a1b2c3",
  "trigger":           "comment_keyword",
  "platform":          "instagram",
  "recipientId":       "17841401234567890",
  "recipientUsername": "jane_doe",
  "keyword":           "LINK",
  "activityId":        "auto_9xKp2Lm4nQ:trg_a1b2c3:comment.received:17912345678901234",
  "actionId":          "act_wh_1",
  "eventId":           "comment.received:17912345678901234",
  "commentId":         "17912345678901234",
  "commentText":       "LINK please",
  "postId":            "17895695668004550",
  "storyId":           null,
  "timestamp":         "2026-05-12T09:14:22.000Z"
}
```

`activityId` is the row `GET /automations/:id/activity` returns, so you can join the two exactly rather than matching on `recipientId` + `triggerId` + `keyword` — which is ambiguous when several people comment the same keyword at once. `actionId` is the `fire_webhook` action that sent the event, and matches `actionResults[].actionId`; it tells two `fire_webhook` actions on the same rule apart.

Fields a trigger doesn't populate are `null` rather than omitted, so the shape is stable. `recipientUsername` is `null` for `dm_keyword`, `dm_reaction` and `story_reply`, because Meta's payload doesn't include one. `commentId` is set only for `comment_keyword` and `story_comment`, `postId` only for `comment_keyword`, and `storyId` only for the story triggers. `story_reply` has no `keyword`. For `dm_keyword`, `commentText` is the DM text; for `dm_reaction`, `commentText` and `keyword` are both the reaction emoji.

<h4 id="automation-completed">
  `automation.completed`
</h4>

Sent only when the automation opts in on its `fire_webhook` action:

```json fire_webhook action with the outcome event on theme={"system"}
{
  "type": "fire_webhook",
  "sendCompletedEvent": true
}
```

```json theme={"system"}
{
  "type":         "automation.completed",
  "automationId": "auto_9xKp2Lm4nQ",
  "triggerId":    "trg_a1b2c3",
  "trigger":      "comment_keyword",
  "platform":     "instagram",
  "recipientId":  "17841401234567890",
  "activityId":   "auto_9xKp2Lm4nQ:trg_a1b2c3:comment.received:17912345678901234",
  "eventId":      "comment.received:17912345678901234",
  "status":       "sent",
  "actionResults": [
    { "actionId": "act_dm_1", "type": "send_dm",      "status": "sent", "errorDetails": null, "messageSource": "callback" },
    { "actionId": "act_wh_1", "type": "fire_webhook", "status": "sent", "errorDetails": null }
  ],
  "errorDetails": null,
  "timestamp":    "2026-05-12T09:14:58.000Z"
}
```

`status` and each `actionResults[].status` use the same values as the [activity log](#activity-statuses) — `sent`, `failed`, `rate_limited`, `deduplicated`, `skipped`, `auth_error` — so in the normal case you learn how a dispatch ended without polling. Delivery of this event is best-effort, though: if the process stops between recording the outcome and queuing the event, it is not sent. The activity log is the record of truth, so if you need every outcome, reconcile against `GET /automations/:id/activity` for any `activityId` that has not reported one. A `send_dm` result also carries `messageSource`, as on the [activity log](#dynamic-message-content): `template`, `callback` or `fallback`, so you can spot a `messageUrl` that keeps falling back without polling. Match results to your configured actions by `actionId`, not by position — two actions of the same type are only distinguishable that way. `actionResults` is empty in three cases, each with the reason on the top-level `errorDetails` where there is one: `rate_limited` (your daily cap was reached), `deduplicated` (every action was inside its dedup window), and `failed` after the dispatch was retried the maximum number of times without finishing. In that last case some actions may already have run, so a DM may have gone out and `automation.triggered` may already have been sent.

Webhook delivery is at-least-once, so the same event can occasionally arrive twice, and a dispatch that is retried can send `automation.triggered` again with a new `hookId`. Never deduplicate on `hookId`. Key each event type separately, so that processing one never suppresses the other: `automation.completed` on `activityId` (each activity has one outcome), and `automation.triggered` on `activityId` plus `actionId` (one per `fire_webhook` action). The two events are delivered independently, so `automation.completed` can occasionally arrive before `automation.triggered`.

#### If no webhook is registered

`fire_webhook` posts to the `automations` action registered on the profile, falling back to your Primary Profile. There is **no** fallback to another action such as `messages`. If neither registration exists, `automation.triggered` is not silently dropped: the action is recorded on the activity row as `failed`, with the reason in `actionResults[].errorDetails`. (`automation.completed` has nowhere to go either, and is not recorded.) The dispatch's overall status then follows the usual precedence: `auth_error` if any action hit an auth error, otherwise `sent` if any action was accepted, otherwise `failed`. So the missing webhook counts toward the automation's failure stats only when nothing else on the rule sent; when another action did send (and none hit an auth error), the dispatch ends `sent` and the failed webhook shows only in `actionResults[]`.

A `fire_webhook` result of `sent` means the event was queued for delivery to your URL, not that your endpoint accepted it. Delivery is then retried on its own schedule.

<h2 id="template-variables">
  Template variables
</h2>

`send_dm.message`, `send_email.subject`, and `send_email.message` support `{{placeholder}}` substitution. **Unknown placeholders are rejected at create/update time** (as a `473` validation error) so a typo never silently leaks the literal `{{foo}}` into a customer-facing message.

| Placeholder | Resolves to |
| - | - |
| `{{recipient_username}}` | The engaging user's Instagram handle (when the webhook carries it) |
| `{{recipient_id}}` | The engaging user's Instagram participant ID (IGSID) |
| `{{recipient_name}}` | Reserved; resolves to empty until a future enrichment source populates it |
| `{{sender_username}}` | Your linked Instagram username |
| `{{sender_name}}` | Your linked Instagram display name |
| `{{comment_text}}` | The text of the comment / DM / story reply that fired the trigger |
| `{{comment_id}}` | The platform id of the comment / message that fired |
| `{{comment_sent_at}}` | ISO 8601 timestamp of the event (when available) |
| `{{matched_keyword}}` | The keyword that matched (or the emoji string for `dm_reaction`) |
| `{{platform}}` | Platform identifier (e.g. `instagram`) |
| `{{trigger_type}}` | Trigger type (e.g. `comment_keyword`) |

<Note>
  **No `sender_email` / `recipient_email`.** These are deliberately not exposed — your billing email has no legitimate place in a DM to a stranger, and Meta does not provide the recipient's email on any IG webhook. Avoiding the placeholders prevents accidental disclosure.
</Note>

Example template:

```
Hey {{recipient_username}}, thanks for the comment "{{comment_text}}" — here is the link you wanted: https://example.com
```

## Rate limits and caps

| Plan | Active automations (per profile) | Daily DM cap (per account) |
| - | - | - |
| Launch & Business | 10 | 1,000 |
| Enterprise | 50 | 5,000 |

The active-automation cap is counted **per [User Profile](/docs/apis/profiles/overview)**, not per parent account. Each profile under your account gets its own Launch/Business 10 or Enterprise 50, so an account with many profiles can run that many automations on each. It counts active automations and is enforced on both `POST` (create) and `PUT` re-activation (`active: false → true`), each surfacing error code `470`. Need a higher per-profile limit? [Contact support](mailto:support@ayrshare.com) to have it raised for your account.

The **daily DM cap** applies per parent Ayrshare account, shared across all your profiles, with a per-profile sub-cap so one busy profile cannot drain the whole account's quota. When a DM cap is hit, the activity row records status `rate_limited` and no DM is sent.

The **messaging conversation limit** also applies. `send_dm` needs the Messaging add-on on the profile, and each DM it sends counts toward that profile's monthly [conversation limit](/docs/apis/messages/overview#conversation-limit-&-pricing), the same as a DM you send yourself. When the limit is reached, a `send_dm` to a new recipient is not sent and the activity row records status `failed`, with the limit message in `actionResults[].errorDetails`. DMs triggered by a comment (`comment_keyword` and `story_comment`) are the exception: they are still sent at the limit, and still counted.

Structural caps on a single automation: **1–50 triggers**, **1–50 actions**.

Instagram itself caps DMs at roughly 200/hour per account. The engine paces dispatch with a 20–60 second jitter to stay safely under this.

<h2 id="activity-statuses">
  Activity statuses
</h2>

A row in `GET /automations/:id/activity` carries a top-level `status` plus a per-action `status` inside `actionResults[]`:

| Status | Meaning |
| - | - |
| `pending` | Just written; the worker hasn't picked it up yet |
| `in_flight` | Worker is currently dispatching |
| `sent` | At least one action was **accepted** by the platform, and none hit an auth error. For `send_dm` this means Instagram accepted the message — **not** that the recipient received it (see the note below) |
| `failed` | No action was accepted, and at least one was rejected by the platform or could not run (and none hit an auth error). The reason is on `actionResults[].errorDetails` |
| `auth_error` | The account's Instagram credentials or messaging permissions could not be used — for example an expired or revoked access token, a missing Facebook Page selection, or Messaging not activated for the profile. The DM was not retried. Per-recipient delivery failures are recorded as `failed`, not here |
| `rate_limited` | The daily DM cap (tier or per-profile) was hit; no DM was sent |
| `deduplicated` | This action already fired to this recipient inside its dedup window |
| `skipped` | The automation was suppressed before dispatch — it became inactive or was deleted between fan-out and dispatch, it has no actions configured, or the engagement that triggered it came from the automation's own account — most often a comment on your own post. In that last case no action runs: no DM (an account cannot message itself), and no `fire_webhook` or `send_email` either. An automation that opted in to [`automation.completed`](/docs/apis/automations/overview#automation-completed) still receives that event, with this status |

`pending` and `in_flight` are transient; everything else is terminal.

When an automation has more than one action, the top-level `status` is derived from the per-action results in this order: `auth_error` if any action hit an auth error, then `sent` if any action was accepted, then `deduplicated` if every action was deduplicated, then `skipped` if every action was deliberately suppressed (skipped or deduplicated), otherwise `failed`. Read `actionResults[]` for the per-action detail.

<Note>
  **`sent` is a platform-acceptance receipt, not a delivery receipt.** Instagram does not expose message delivery to any API. A `send_dm` action is marked `sent` the moment Instagram accepts the message; whether the recipient actually receives it depends on their Instagram **Message requests** setting, which Ayrshare cannot read or influence. See [Automation DM Sent but Not Delivered](/docs/help-center/technical-support/automation_dm_sent_but_not_delivered).
</Note>

## Error codes

The API returns two shapes of error:

* **Business-rule errors** carry a numbered automation `code` (e.g. `{ "action": "automation", "code": 469, ... }`).
* **Validation errors** — any malformed request body (missing or invalid fields, unknown template variables, unrecognized keys) — are returned as a single **`473`** response with a `details` object that lists the offending fields. `details` is the validator's output (`formErrors` plus `fieldErrors`). Branch on `details`, not on a per-condition code. In `fieldErrors`, keys are the top-level request fields (`triggers`, `actions`): a problem inside a specific entry, such as a trigger missing its `keywords`, is reported under that field (e.g. `triggers`), while `formErrors` holds object-level issues such as unrecognized keys.

| Code | HTTP | Meaning |
| - | - | - |
| 468 | 403 | Business or Enterprise plan required |
| 469 | 404 | Automation not found (also returned when the caller doesn't own it) |
| 470 | 429 | Active automation cap reached for your plan tier |
| 471 | 400 | No social account linked on the requested platform for this profile |
| 472 | 403 | Feature not yet available on your account — contact us for early access |
| 473 | 400 | Validation failed (malformed request body) — inspect `details` |

<Note>
  **Launch plans are included.** Business Launch resolves to the Business tier, so Automations
  are available on Launch, Business, and Enterprise. `468` is only returned to plans that do
  not include Automations.
</Note>

## What Meta does NOT allow

A few commonly-requested capabilities are not supported because Meta doesn't permit them on the public Instagram API:

* **Auto-DM on new followers.** Instagram does not publish a follow webhook.
* **First-message DMs to strangers.** Meta requires the recipient to have engaged first (comment, reply, DM, reaction) before a business account can message them. Every supported trigger is anchored to such an engagement — but note that being *allowed* to send is not the same as the message being *delivered*: the recipient's Instagram **Message requests** setting can still cause Meta to accept and then silently drop the message (see [Automation DM Sent but Not Delivered](/docs/help-center/technical-support/automation_dm_sent_but_not_delivered)).
* **Bulk outbound campaigns.** Hourly DM caps and anti-abuse heuristics apply at the platform level.

## Multi-profile usage

The endpoints respect the `profileKey` header. Pass a child profile's key and the automation is created/managed under that profile. Rate limits split across profiles via a per-profile sub-cap so one chatty profile doesn't drain the parent account's quota.

## FAQ

<AccordionGroup>
  <Accordion title="Can I trigger on a new follower?">
    No. Instagram does not publish a follow webhook, and Meta does not allow third-party apps to send a DM to a user who has not engaged first. Every supported trigger (`comment_keyword`, `story_reply`, `dm_reaction`, `dm_keyword`) is anchored to such an engagement, which is what makes the send permissible.
  </Accordion>

  <Accordion title="Why does an activity say `sent` but the recipient never got the DM?">
    `sent` means Instagram **accepted** the message, not that it was delivered. Delivery depends on the recipient's Instagram **Message requests** setting — if they don't allow message requests from everyone, Meta returns a success response and silently drops the message, with no error, webhook, or other signal on any API surface. A successful comment-triggered DM also arrives as a **message request the recipient must accept** (unless a conversation already exists). This is a permanent Instagram platform limitation. See [Automation DM Sent but Not Delivered](/docs/help-center/technical-support/automation_dm_sent_but_not_delivered).
  </Accordion>

  <Accordion title="What happens if my access token is invalid when an automation fires?">
    If the account's Instagram credentials or messaging permissions genuinely fail — for example an expired or revoked access token, a missing Facebook Page selection, or Messaging not activated for the profile — the activity row records status `auth_error` and the DM is not retried. Resolving the account state (relinking, selecting a Page, or activating Messaging) lets the next matching engagement fire normally. This only applies to genuine credential and permission failures: if a specific DM could not be delivered for another reason — for example the recipient could not be found, or the triggering engagement came from the account that would send the DM — the row is recorded as `failed` or `skipped` with the reason on `actionResults[].errorDetails`, and relinking will not change the outcome.
  </Accordion>

  <Accordion title="Why is there a delay before the DM is sent?">
    Each dispatch is scheduled 20–60 seconds after the engagement, so DM sends look organic to Instagram's anti-spam systems. The delay is applied to the engagement **before** any rule is matched, so it applies to every action on the rule, `fire_webhook` and `send_email` included — expect your webhook to arrive 20–60 seconds after the comment, not immediately. The activity row's `created` timestamp is when the trigger matched; `completedAt` is when dispatch finished.
  </Accordion>

  <Accordion title="Are activity rows kept forever?">
    Activity rows are retained indefinitely for trace and analytics. The `GET /automations/:id/activity` endpoint returns rows from the last 30 days for performance. (The dedup guard uses its own per-action window — defaulting to 7 days — which is unrelated to the activity lookback.)
  </Accordion>

  <Accordion title="Does deleting an automation remove its activity history?">
    No. Delete is a soft-delete: the master row is flagged `deleted`, no new dispatches occur, but historical activity rows remain readable via the activity endpoint.
  </Accordion>
</AccordionGroup>

## Endpoints

* [`POST /automations`](/docs/apis/automations/create-automation) — create a new automation
* [`GET /automations`](/docs/apis/automations/list-automations) — list your automations
* [`GET /automations/:id`](/docs/apis/automations/get-automation) — fetch one automation with its triggers and actions
* [`PUT /automations/:id`](/docs/apis/automations/update-automation) — partial update; pause via `active: false`
* [`DELETE /automations/:id`](/docs/apis/automations/delete-automation) — soft-delete
* [`GET /automations/:id/activity`](/docs/apis/automations/get-activity) — cursor-paginated dispatch audit log
