# Automate email from events

> Event-triggered workflows that wait, send, branch on engagement or contact data and update contacts, with versions, runs and step history, through the API and the console builder.

Source: https://docs.minket.com/automations/
Updated: 2026-10-29

An automation is a trigger, an optional entry rule and a graph of steps. When the trigger fires for a contact, a run starts: it walks the steps one at a time, waits where the graph waits, and keeps a history of every step it took. Each run belongs to one contact and finishes on the version of the automation it started on. Build automations in the console (Automations, a visual builder with the runs beside it) or with the API.

## Build one

```sh
curl https://api.minket.com/v1/automations \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order follow-up",
    "definition": {
      "trigger": { "kind": "custom_event", "event": "order.paid" },
      "filter": { "all": [{ "field": "unsubscribed", "op": "eq", "value": false }] },
      "allow_reentry": true,
      "start": "receipt",
      "steps": [
        { "id": "receipt", "type": "send_email", "template_id": "receipt", "variables": { "order": "{{event.payload.order_id}}" }, "next": "pause" },
        { "id": "pause", "type": "wait", "duration": { "amount": 3, "unit": "days" }, "next": "opened" },
        { "id": "opened", "type": "branch", "engagement": { "event": "email.opened", "step_id": "receipt" }, "yes": "tag", "no": "nudge" },
        { "id": "nudge", "type": "send_email", "template_id": "review-request", "next": "tag" },
        { "id": "tag", "type": "update_contact", "properties": { "last_order": "{{event.payload.order_id}}" }, "next": null }
      ]
    }
  }'
```

It is created as a draft. Publishing turns it on; from then on every `order.paid` event for a contact starts a run:

```sh tab=curl
curl -X POST https://api.minket.com/v1/automations/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a90/publish \
  -H "Authorization: Bearer $MINKET_API_KEY"

curl https://api.minket.com/v1/contacts/events \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event": "order.paid", "email": "jane@example.com", "payload": { "order_id": "A-1017" } }'
```

```ts tab
await minket.automations.publish("019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a90");
await minket.contacts.sendEvent({
  event: "order.paid",
  email: "jane@example.com",
  payload: { order_id: "A-1017" },
});
```

The event answers `202 { "object": "event", "event": "order.paid", "runs_started": 1 }`.

## Triggers

| `kind` | A run starts when |
|---|---|
| `custom_event` | Your app sends the named event for a contact (`POST /v1/contacts/events`). |
| `contact_created` | A contact is created. |
| `contact_updated` | A contact changes; with `properties`, only when one of those keys gets a new value. |
| `topic_subscribed` | A contact chooses to receive `topic_id` (an opt-in recorded in the consent ledger). |
| `segment_entered` | A created or changed contact is in `segment_id`. With re-entry on, it starts again only after the contact was seen outside the segment. |
| `email_event` | An email event of `type` (`email.delivered`, `email.opened`, `email.bounced`, ...) happens on mail to a contact, optionally only for mail with a `tag` `{ "name", "value" }`. |

`filter` is an entry rule in the [segment rule format](https://docs.minket.com/audience/#segments): a contact who does not match starts no run. A contact is in at most one run of an automation at a time (one live and one test-mode run: a test key's run never holds back a live one, nor counts against its daily cap); `allow_reentry` lets them start again once their run has ended.

Test keys read automations and their runs but never change them ([API keys](https://docs.minket.com/keys/#scopes)), since a published automation runs on live events. A test key's events start test-mode runs, and wake only test-mode runs waiting for them: a live run never moves on a test key's event. A test-mode run's mail is test mail (it lands in the sandbox), its contact, topic, segment and delete steps change nothing (they are recorded with the output `{ "test_mode": true }`), and only test keys and the console see it.

## Steps

| `type` | Fields | Moves to |
|---|---|---|
| `send_email` | `template_id` (an id or alias; its published version is sent), `variables`, optional `subject`, `from`, `reply_to` overrides, `stream` (else the from domain's), `topic_id` | `next` |
| `wait` | `duration`: `{ "amount": 3, "unit": "days" }`, in `minutes`, `hours` or `days`, up to 90 days | `next` |
| `wait_for_event` | `event`: `email.opened` or `email.clicked` (an email this run sent), or `custom_event` with `custom_event` naming it (an event for the contact); `timeout` | `next` when it happens, `timeout_next` when the timeout passes first |
| `branch` | `condition` (segment rules on the contact's fields, properties and topics) or `engagement`: `{ "event": "email.opened" or "email.clicked", "step_id" }` (one send step's email, or any email of the run when `step_id` is null; a click counts as an open) | `yes` or `no` |
| `update_contact` | any of `first_name`, `last_name`, `unsubscribed`, `properties` | `next` |
| `segment` | `action` (`add` or `remove`), `segment_id` of a static segment | `next` |
| `topic` | `action` (`subscribe` or `unsubscribe`), `topic_id` | `next` |
| `delete_contact` | | ends the run |
| `exit` | | ends the run |

A `next` of `null` ends the run there. Step ids use letters, digits, `_` and `-` and are unique within the definition; a definition holds up to 100 steps.

Values in `variables`, the overrides and `update_contact` may name what the run knows: `{{contact.email}}`, `{{contact.first_name}}`, `{{contact.last_name}}`, `{{contact.properties.plan}}`, `{{event.name}}` and `{{event.payload.order_id}}` (any path into the payload). A value that is exactly one placeholder keeps its type, so `"{{event.payload.total}}"` stays a number. The template's own variables are filled from the contact ([Variables](https://docs.minket.com/templates/#variables)), and a step's `variables` win over the contact's. A custom event's payload is at most 16 KB of JSON. For email-event triggers the payload holds the email id and its tags only, never the subject or the addresses.

Every step goes through the services every other caller uses. Sends go through the send service, so consent, suppressions, topics, the kill switch, the daily cap and the content checks all apply: an unsubscribed contact's marketing mail is accepted as `suppressed` and the run goes on. Marketing mail gets the contact's unsubscribe and preferences links in its footer, as a broadcast's does, so its template needs no unsubscribe link of its own. A send refused for a reason that passes (the daily cap, a paused project) waits and is tried again about every ten minutes; any other refusal fails the run. Sends are idempotent per run and step, so a step that runs twice sends once. The consent ledger records the automation's contact, segment and topic changes with the source `automation`, and their `contact.updated` events say which run made them.

## Versions

Saving a definition (`PATCH /v1/automations/{id}` with `definition`) changes the draft; once a version is published, the next save starts a new version. `POST /v1/automations/{id}/publish` freezes the current version and makes it the one new runs start on. Runs in flight keep the version they started on, to the end. `GET /v1/automations/{id}/versions` lists them, newest first, with `published_at`.

Publishing checks what the schema cannot: every step is reachable from `start`, no steps loop back without a wait between them, and the templates, segments and topics it names exist (templates published, segments static). It answers `422 validation_error` with an issue per problem, `path` naming the step (`steps.nudge`). The console shows the same issues on the nodes while you build.

`POST /v1/automations/{id}/stop` (or `PATCH` with `status: "paused"`) turns it off and stops its running and waiting runs: they schedule no further step. `DELETE /v1/automations/{id}` archives it the same way. `status: "active"` turns it on again.

`POST /v1/automations/{id}/duplicate` makes a new draft named `<name> (copy)` (cut to 200 characters) from the current version, the newest draft if it is newer than the published one, and answers `201` with the copy. Runs and the published version stay with the original, so the copy does nothing until it is published.

## Runs

`GET /v1/automations/{id}/runs?status=&contact_id=` lists runs newest first; `GET /v1/automations/{id}/runs/{runId}` returns one with its `steps`, one entry per step it took (`status`, `started_at`, `finished_at`, `output` such as the `email_id` a send made, and `error`).

| Run `status` | Means |
|---|---|
| `running` | Executing steps. |
| `waiting` | In a `wait` or `wait_for_event` until `wait_until`. |
| `completed` | Reached the end of its path. |
| `exited` | Reached an `exit` step, or its contact was deleted. |
| `failed` | A step could not be done; `error` says why. |
| `stopped` | Its automation was stopped or archived. |

Runs report their end through `automation.completed` and `automation.failed` events ([payloads](https://docs.minket.com/webhooks/#payloads)). A wake-up that a run has moved past (woken by its event, stopped, waiting again) does nothing.

## Runaway guard

- A contact starts at most 50 runs of one automation per day.
- A run takes at most 500 steps; a loop through a wait ends there.
- A loop without a wait is refused at publishing, and a run that comes back to a step without waiting in between fails.
- An automation's own changes never start it again, and runs started by another automation's changes stop three automations deep.
