Sign in Create account
Marketing

Automations API: triggers, steps and A/B tests

Email sequences and actions fired by events, new contacts, segments, forms, tags, opens and clicks, with A/B tests, versions, scheduling and a log of every run.

Automations send emails and take care of contacts on their own, starting from a trigger: an event from your product, a new contact, entering or leaving a segment, a topic subscription or a form signup. Each contact that fires the trigger gets a run, which goes through the steps (wait, send an email, check a condition, change the contact, call a webhook) and records what happened in each one. All endpoints require a full-access key.

Triggers also include a tag the contact receives, an email being opened or clicked and a change to a contact field or property; the steps include waiting until the contact opens, clicks or replies to an email (wait_for) and splitting contacts among variants in an A/B test (experiment). An automation can be activated and paused at scheduled times.

#Draft, active and paused

StatusHow it works
draftCreated this way. It can be saved incomplete (an email with no template, a condition with no paths); what is missing appears in issues. It does not start runs.
activeAfter you activate it: the definition has to be complete and becomes a version (version). Each trigger starts a run.
pausedAfter you pause it: it does not start runs, and the ones that are waiting stay on hold until the automation is activated again.

Versions. Editing an active automation publishes a new version right away (if the trigger, the steps or reentry changed). Only new runs follow the new version: runs already in progress finish on the version they started with (the run's version). Changing only the name does not create a version.

Re-entry. With reentry: "once" (default), each contact goes through the automation only once. With "every_time", each trigger starts a new run, even if another one is in progress.

Plan limit. The runs started in the month (in UTC), across all automations, are limited per plan (see Limits and automation_runs in GET /usage). Over the limit, the run is recorded as skipped, with the reason, and does not count toward the limit.

Scheduling. POST /automations/{id}/schedule sets the time the automation is activated (activate_at) and/or paused (deactivate_at), for example for a promotion with a start and an end. The times appear on the automation and are applied every minute; once applied, the field goes back to null. An automation scheduled to activate has to be complete, like an active one.

Plan limits. Besides runs per month, the plan can limit how many automations the team has (automations, which only applies on creation: creating past the limit responds 403 plan_limit_reached) and whether it includes A/B tests (automation_experiments). The numbers for each plan are in GET /plans. When the plan shrinks and allows fewer automations than the team has, the newest ones above the limit get paused_by_plan: true: they stay listed and readable, but do not start runs, and editing, activating, scheduling or declaring a winner responds 403 paused_by_plan until the team is back within the limit (by upgrading or deleting others).

#Trigger and steps

An automation is a trigger and a list of steps. Steps run in order; a condition closes the list and continues on one of its paths (branches.yes or branches.no), each with its own list; an experiment continues on the path of the variant that was drawn (branches.a, branches.b… or branches.control). The end of a list ends the run. An automation can have up to 100 steps in total. A wait_for also closes the list and continues in branches.yes or branches.no.

Triggers

typeFieldsWhen it startsExample
eventevent: the event name. filters: up to 10 comparisons against the event properties; all of them must match.An event with that name arrives with a contact.{"type": "event", "event": "order.paid", "filters": [{"key": "total", "operator": "gte", "value": 500}]}
contact.created—A contact is created through the API, a form or an event with a new email. Contacts from a CSV import only trigger it when the import asks for it (trigger_automations, see Import a CSV).{"type": "contact.created"}
segment.addedsegment_idA contact enters the segment: through the API, a form or an update_contact step, or, in a dynamic segment, when it starts matching the filters (once per entry, also announced by the contact.segment_entered webhook). Saving a dynamic segment fires nothing for those who already match it. Imports only trigger it with trigger_automations.{"type": "segment.added", "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"}
segment.removedsegment_idA contact leaves the segment: removed from a static segment (through the API, the dashboard or an update_contact step) or no longer meets the filters of a dynamic segment when it is recalculated. Each exit triggers once (and the contact.segment_exited webhook announces it). Imports do not trigger it.{"type": "segment.removed", "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a"}
topic.subscribedtopic_idThe contact's choice for the topic becomes opt_in (before, there was no choice or it was opt_out): on the preferences page, in a form, through the API or by an automation. Imports and the automation that made the change itself do not trigger it.{"type": "topic.subscribed", "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"}
topic.unsubscribedtopic_idThe opposite: the choice becomes opt_out.{"type": "topic.unsubscribed", "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"}
form.submittedform_idSomeone signs up through the form (with double opt-in, after confirming).{"type": "form.submitted", "form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d"}
tag.addedtag_id (or tag_name, for a tag that does not exist yet: it is created when you save)The contact receives a tag it did not have: through the API, the dashboard or an update_contact step. Imports only trigger it with trigger_automations. See Contacts.{"type": "tag.added", "tag_name": "vip-customer"}
email.openedOptional filters: template_id, broadcast_id and automation_id; every one you give has to match the email.A contact opens an email. A click also counts as an open, since opens are not measured when images are blocked. The contact is the one the email of a broadcast or automation went to or, for other emails, the one with the first recipient's address. Each automation starts at most one run for the same contact and email.{"type": "email.opened", "broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"}
email.clickedThe same filters as email.opened, plus link_contains: part of the clicked link (case-insensitive, up to 500 characters).A contact clicks a link in an email. A click starts both the email.clicked and the email.opened automations.{"type": "email.clicked", "link_contains": "/checkout"}
contact.property_changedproperty: first_name, last_name, email, unsubscribed, tags or the key of a contact property. Optional: from (the value before) and to (the value after); empty matches any value.The field or property changes, by any path (API, dashboard, form, an event with contact_properties or another automation). A new contact counts as a change from empty to each value it was created with. For tags, to is the tag added and from the tag removed. Text is compared case-insensitively and numbers as numbers. The automation that made the change, in an update_contact step, is not started again by it; imports never trigger it.{"type": "contact.property_changed", "property": "plan", "from": "free", "to": "pro"}

Steps

Each step is {"id", "type", "config"}. The id (letters, numbers, _ and -, up to 64 characters, unique within the automation) identifies the step in conditions and in the run log; without one, Postal generates one such as step_k3x9p2qa7m. Unknown keys are discarded.

typeWhat it doesconfig
waitWaits for a period of time or until a time of day.{"mode": "duration", "amount": 2, "unit": "days"} (units: minutes, hours, days, weeks; up to 365 days) or {"mode": "until", "time": "09:00", "weekdays": [1, 2, 3, 4, 5], "timezone": "America/Sao_Paulo"}: the next 09:00, only on the listed weekdays (1 = Monday, 7 = Sunday; empty = any day). Default time zone: America/Sao_Paulo.
wait_forWaits for the contact to open, click or reply to an email sent earlier, up to a deadline.{"step_id": "welcome", "activity": "opened", "timeout": {"amount": 2, "unit": "days"}}. Follows branches.yes as soon as it happens or branches.no when the deadline passes. Always goes at the end of its list. See Waiting for an email action.
send_emailSends a published template to the contact.template_id (ID or alias, required), from (default: the template's), subject (instead of the template's subject), reply_to and variables. See Variables. With detect_replies: true, Postal detects replies to the email, which wait_for and the reply conditions need (see Reply detection).
conditionFollows the yes or no path.Always goes at the end of its list; the following steps go in branches.yes and branches.no. See Conditions.
update_contactChanges the contact.actions, up to 20: {"type": "set_property", "key": "plan", "value": "pro"} (first_name, last_name or a property; the value accepts {{event.*}}), add_to_segment and remove_from_segment (with segment_id), subscribe_topic and unsubscribe_topic (with topic_id), unsubscribe and resubscribe. Also add_tag and remove_tag (with tag_id or, for a tag that does not exist yet, tag_name: it is created when you save). Dynamic segments do not accept add_to_segment or remove_from_segment: only static ones.
webhookSends the contact and the event to a URL of yours.{"url": "https://…"}. See Webhook step.
experimentSplits contacts among variants (A/B test).2 to 4 variants, each one a path in branches, and an optional control group. Always goes at the end of its list. See Experiments.
exitEnds the run.No configuration; goes at the end of its list. The end of a list already ends the run, so use exit only to make that explicit.

Conditions

kindFieldsExample
contact_propertykey (email, first_name, last_name or a property), operator, value{"kind": "contact_property", "key": "plan", "operator": "equals", "value": "pro"}
event_propertykey (path into the properties of the event that started the run, such as items.0.name), operator, value{"kind": "event_property", "key": "total", "operator": "gt", "value": 200}
segmentsegment_id, operator: in or not_in{"kind": "segment", "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a", "operator": "in"}
topictopic_id, operator: subscribed or not_subscribed{"kind": "topic", "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b", "operator": "subscribed"}
tagtag_id (or tag_name), operator: has or not_has{"kind": "tag", "tag_id": "019a1d85-6c7d-7e8f-9a0b-1c2d3e4f5a6b", "operator": "has"}
email_activitystep_id of an earlier send_email on the same path, activity: opened or clicked. A click counts as an open. It also accepts clicked_link (with link_contains, part of the clicked link) and replied (the email needs detect_replies). A reply that is not automatic also counts as an open.{"kind": "email_activity", "step_id": "welcome", "activity": "opened"}

Comparison operators (in property conditions and trigger filters): equals, not_equals, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte, is_set and is_not_set (these two take no value). Text is compared case-insensitively; numbers as numbers; dates in the YYYY-MM-DD format, in calendar order.

Waiting for an email action

The wait_for step holds the run until the contact opens, clicks or replies to an email sent by an earlier send_email on the same path. If the action happens, the run follows branches.yes right away; if the timeout passes first, it follows branches.no. It beats a wait followed by an activity condition: it reacts as soon as the person acts, instead of looking only at the end of the period.

wait_for step
{
  "id": "wait_open",
  "type": "wait_for",
  "config": {
    "step_id": "welcome",
    "activity": "opened",
    "timeout": {
      "amount": 2,
      "unit": "days"
    }
  },
  "branches": {
    "yes": [
      {
        "id": "tag_engaged",
        "type": "update_contact",
        "config": {
          "actions": [
            {
              "type": "add_to_segment",
              "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
            }
          ]
        }
      }
    ],
    "no": [
      {
        "id": "reminder",
        "type": "send_email",
        "config": {
          "template_id": "trial-help",
          "subject": "{{contact.first_name}}, can I help with your trial?"
        }
      }
    ]
  }
}
configWhat it is
step_idThe id of an earlier send_email on the same path. Required.
activityopened (default; a click or a reply also counts), clicked, clicked_link or replied.
link_containsOnly with clicked_link: part of the clicked link, case-insensitive, up to 500 characters.
timeout{"amount": 2, "unit": "days"}: how long to wait at most (minutes, hours, days or weeks; up to 365 days). Required.
  • replied requires detect_replies: true on the send_email step; without it, the automation cannot be activated. Automatic replies (out of office) do not count.
  • If the email of the step_id was not sent in this run (for example, the contact is unsubscribed and the send was skipped), the run follows branches.no.
  • In the run log, the output says how it ended: resolved_by is event (with resolved_at), timeout or no_email.

Variables in emails

The send_email step uses the published version of the template, like a POST /emails with template: same validations, sending quota and suppression list. The template variables ({{{NAME}}}) are filled in automatically with:

  • the contact's EMAIL, FIRST_NAME, LAST_NAME and CONTACT_ID;
  • each contact property and each top-level property of the event, by key and in uppercase (plan and PLAN);
  • POSTAL_UNSUBSCRIBE_URL, the link to the contact's preferences page;
  • and the step's variables, which accept references such as {{contact.first_name}}, {{contact.plan}} and {{event.items.0.name}} (missing values are left empty).

The step's subject and the values of set_property accept the same {{contact.*}} and {{event.*}} references. Emails carry the one-click List-Unsubscribe headers; include {{{POSTAL_UNSUBSCRIBE_URL}}} in the template to show the link in the body. Unsubscribed contacts (unsubscribed) do not receive it: the step is skipped and the run continues. If the send fails (quota exhausted, unverified domain, variable with no value), the step and the run become failed.

#Experiments (A/B test)

The experiment step splits the contacts that reach it among 2 to 4 variants, by the percentage of each, to compare subjects, content or waits. Each variant is a path in branches, with the variant's key (lowercase letters, numbers and _, such as a and b). With control_percent above 0, that share of contacts goes to the control path, which can be empty: those contacts skip the variants and end there. The percentages of the variants plus the control add up to 100. Keys start with a letter, have up to 32 characters and cannot be control; a variant without a key gets the next free letter (a, b…).

Experiment step
{
  "id": "subject_test",
  "type": "experiment",
  "config": {
    "variants": [
      {
        "key": "a",
        "name": "Short subject",
        "percent": 45
      },
      {
        "key": "b",
        "name": "Subject with the name",
        "percent": 45
      }
    ],
    "control_percent": 10,
    "goal_event": "order.paid"
  },
  "branches": {
    "a": [
      {
        "id": "email_short",
        "type": "send_email",
        "config": {
          "template_id": "trial-welcome",
          "subject": "Your trial has started"
        }
      }
    ],
    "b": [
      {
        "id": "email_named",
        "type": "send_email",
        "config": {
          "template_id": "trial-welcome",
          "subject": "{{contact.first_name}}, your trial has started"
        }
      }
    ],
    "control": []
  }
}
  • Always the same variant. The variant comes from a deterministic draw based on the run and the step, and is stored: the run always follows the same path, and each run shows up with {"variant", "reason"} in the step log.
  • Conversion. With goal_event, an event with that name sent for the contact after they enter the variant counts as a conversion.
  • Results. GET /automations/{id}/experiments returns, per variant, how many contacts entered, the emails sent after the split and how many of those contacts opened, clicked, replied (auto-replies do not count) and converted.
  • Winner. POST /automations/{id}/experiments/{step_id}/winner sends all the next contacts to the chosen variant, on any version of the automation, and records who declared it and when. Contacts that already went through stay on the variant they got.
  • Changing the split. The variants and percentages of an experiment can only change while the automation is paused (on an active one, the response is 422). Names and the steps inside the variants can change at any time.
  • Plan. Experiments depend on the plan's automation_experiments feature. Without it, creating, saving, activating or scheduling an automation with an experiment responds 403 plan_limit_reached. If the team loses the feature while the automation is active, it keeps running and all contacts go to the first variant. The automation detail includes experiments_included.

#List automations

GET/automations

Full-access key

Each automation with its definition, the current version (null on a draft that was never activated), runs_this_month (runs started in the month) and runs_in_progress.

Query parameters

  • limitinteger
    1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name.
  • statusstring
    draft, active or paused.
  • scheduledboolean
    With true, only the automations with a scheduled activation or pause.
GET /automations

cURL

curl 'https://api.postal.so/automations?status=active' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations?status=active', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/automations",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "status": "active",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('automations', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'status' => 'active',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "automation",
      "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
      "paused_by_plan": false,
      "name": "Free trial welcome",
      "status": "active",
      "trigger": {
        "type": "event",
        "event": "trial.started",
        "filters": [
          {
            "key": "plan",
            "operator": "equals",
            "value": "pro"
          }
        ]
      },
      "reentry": "once",
      "steps": [
        {
          "id": "welcome",
          "type": "send_email",
          "config": {
            "template_id": "trial-welcome",
            "from": "Jane from Acme Store <[email protected]>",
            "subject": null,
            "reply_to": null,
            "variables": {
              "DAYS": "{{event.trial_days}}"
            }
          }
        },
        {
          "id": "wait_2_days",
          "type": "wait",
          "config": {
            "mode": "duration",
            "amount": 2,
            "unit": "days"
          }
        },
        {
          "id": "check_opened",
          "type": "condition",
          "config": {
            "kind": "email_activity",
            "step_id": "welcome",
            "activity": "opened"
          },
          "branches": {
            "yes": [
              {
                "id": "tag_engaged",
                "type": "update_contact",
                "config": {
                  "actions": [
                    {
                      "type": "add_to_segment",
                      "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                    }
                  ]
                }
              }
            ],
            "no": [
              {
                "id": "wait_morning",
                "type": "wait",
                "config": {
                  "mode": "until",
                  "time": "09:00",
                  "weekdays": [1, 2, 3, 4, 5],
                  "timezone": "America/Sao_Paulo"
                }
              },
              {
                "id": "reminder",
                "type": "send_email",
                "config": {
                  "template_id": "trial-help",
                  "from": null,
                  "subject": "{{contact.first_name}}, can I help with your trial?",
                  "reply_to": null,
                  "variables": {}
                }
              },
              {
                "id": "notify_crm",
                "type": "webhook",
                "config": {
                  "url": "https://crm.acmestore.com/hooks/postal"
                }
              }
            ]
          }
        }
      ],
      "version": 2,
      "runs_this_month": 318,
      "runs_in_progress": 41,
      "activated_at": "2026-10-15T13:00:00.000000Z",
      "activate_at": null,
      "deactivate_at": null,
      "created_at": "2026-10-14T19:22:00.000000Z",
      "updated_at": "2026-10-20T10:31:00.000000Z"
    }
  ]
}

#Create an automation

POST/automations

Full-access key

Creates a draft. Structural errors reject the save (an unknown step type, a step after a condition, a segment from another team, a repeated ID); what is merely missing goes into issues and only blocks activation. It returns the full automation, with issues and the webhook_signing_secret used in webhook steps.

Request body

  • namestringrequired
    Name, up to 255 characters.
  • triggerobjectrequired
    The trigger, with type.
  • stepsobject[]
    The steps. It can be empty on a draft.
  • reentrystring
    once (default) or every_time.

Errors

  • 422validation_error
    Invalid field or a definition with errors. The extra errors field lists each problem, with the step_id (or null, for the trigger) and the message.
  • 403plan_limit_reached
    The definition has an experiment and the plan does not include A/B tests.
  • 403plan_limit_reached
    The plan does not allow more automations (the automations limit).
POST /automations

cURL

curl -X POST https://api.postal.so/automations \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Free trial welcome",
    "trigger": {
      "type": "event",
      "event": "trial.started",
      "filters": [
        {
          "key": "plan",
          "operator": "equals",
          "value": "pro"
        }
      ]
    },
    "steps": [
      {
        "id": "welcome",
        "type": "send_email",
        "config": {
          "template_id": "trial-welcome",
          "from": "Jane from Acme Store <[email protected]>",
          "variables": {
            "DAYS": "{{event.trial_days}}"
          }
        }
      },
      {
        "id": "wait_2_days",
        "type": "wait",
        "config": {
          "mode": "duration",
          "amount": 2,
          "unit": "days"
        }
      },
      {
        "id": "check_opened",
        "type": "condition",
        "config": {
          "kind": "email_activity",
          "step_id": "welcome",
          "activity": "opened"
        },
        "branches": {
          "yes": [
            {
              "id": "tag_engaged",
              "type": "update_contact",
              "config": {
                "actions": [
                  {
                    "type": "add_to_segment",
                    "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                  }
                ]
              }
            }
          ],
          "no": [
            {
              "id": "wait_morning",
              "type": "wait",
              "config": {
                "mode": "until",
                "time": "09:00",
                "weekdays": [1, 2, 3, 4, 5],
                "timezone": "America/Sao_Paulo"
              }
            },
            {
              "id": "reminder",
              "type": "send_email",
              "config": {
                "template_id": "trial-help",
                "subject": "{{contact.first_name}}, can I help with your trial?"
              }
            },
            {
              "id": "notify_crm",
              "type": "webhook",
              "config": {
                "url": "https://crm.acmestore.com/hooks/postal"
              }
            }
          ]
        }
      }
    ]
  }'

Node.js

const response = await fetch('https://api.postal.so/automations', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Free trial welcome',
    trigger: {
      type: 'event',
      event: 'trial.started',
      filters: [
        {
          key: 'plan',
          operator: 'equals',
          value: 'pro',
        },
      ],
    },
    steps: [
      {
        id: 'welcome',
        type: 'send_email',
        config: {
          template_id: 'trial-welcome',
          from: 'Jane from Acme Store <[email protected]>',
          variables: {
            DAYS: '{{event.trial_days}}',
          },
        },
      },
      {
        id: 'wait_2_days',
        type: 'wait',
        config: {
          mode: 'duration',
          amount: 2,
          unit: 'days',
        },
      },
      {
        id: 'check_opened',
        type: 'condition',
        config: {
          kind: 'email_activity',
          step_id: 'welcome',
          activity: 'opened',
        },
        branches: {
          yes: [
            {
              id: 'tag_engaged',
              type: 'update_contact',
              config: {
                actions: [
                  {
                    type: 'add_to_segment',
                    segment_id: '019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c',
                  },
                ],
              },
            },
          ],
          no: [
            {
              id: 'wait_morning',
              type: 'wait',
              config: {
                mode: 'until',
                time: '09:00',
                weekdays: [1, 2, 3, 4, 5],
                timezone: 'America/Sao_Paulo',
              },
            },
            {
              id: 'reminder',
              type: 'send_email',
              config: {
                template_id: 'trial-help',
                subject: '{{contact.first_name}}, can I help with your trial?',
              },
            },
            {
              id: 'notify_crm',
              type: 'webhook',
              config: {
                url: 'https://crm.acmestore.com/hooks/postal',
              },
            },
          ],
        },
      },
    ],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automations",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "name": "Free trial welcome",
        "trigger": {
            "type": "event",
            "event": "trial.started",
            "filters": [
                {
                    "key": "plan",
                    "operator": "equals",
                    "value": "pro",
                },
            ],
        },
        "steps": [
            {
                "id": "welcome",
                "type": "send_email",
                "config": {
                    "template_id": "trial-welcome",
                    "from": "Jane from Acme Store <[email protected]>",
                    "variables": {
                        "DAYS": "{{event.trial_days}}",
                    },
                },
            },
            {
                "id": "wait_2_days",
                "type": "wait",
                "config": {
                    "mode": "duration",
                    "amount": 2,
                    "unit": "days",
                },
            },
            {
                "id": "check_opened",
                "type": "condition",
                "config": {
                    "kind": "email_activity",
                    "step_id": "welcome",
                    "activity": "opened",
                },
                "branches": {
                    "yes": [
                        {
                            "id": "tag_engaged",
                            "type": "update_contact",
                            "config": {
                                "actions": [
                                    {
                                        "type": "add_to_segment",
                                        "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c",
                                    },
                                ],
                            },
                        },
                    ],
                    "no": [
                        {
                            "id": "wait_morning",
                            "type": "wait",
                            "config": {
                                "mode": "until",
                                "time": "09:00",
                                "weekdays": [1, 2, 3, 4, 5],
                                "timezone": "America/Sao_Paulo",
                            },
                        },
                        {
                            "id": "reminder",
                            "type": "send_email",
                            "config": {
                                "template_id": "trial-help",
                                "subject": "{{contact.first_name}}, can I help with your trial?",
                            },
                        },
                        {
                            "id": "notify_crm",
                            "type": "webhook",
                            "config": {
                                "url": "https://crm.acmestore.com/hooks/postal",
                            },
                        },
                    ],
                },
            },
        ],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automations', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'name' => 'Free trial welcome',
        'trigger' => [
            'type' => 'event',
            'event' => 'trial.started',
            'filters' => [
                [
                    'key' => 'plan',
                    'operator' => 'equals',
                    'value' => 'pro',
                ],
            ],
        ],
        'steps' => [
            [
                'id' => 'welcome',
                'type' => 'send_email',
                'config' => [
                    'template_id' => 'trial-welcome',
                    'from' => 'Jane from Acme Store <[email protected]>',
                    'variables' => [
                        'DAYS' => '{{event.trial_days}}',
                    ],
                ],
            ],
            [
                'id' => 'wait_2_days',
                'type' => 'wait',
                'config' => [
                    'mode' => 'duration',
                    'amount' => 2,
                    'unit' => 'days',
                ],
            ],
            [
                'id' => 'check_opened',
                'type' => 'condition',
                'config' => [
                    'kind' => 'email_activity',
                    'step_id' => 'welcome',
                    'activity' => 'opened',
                ],
                'branches' => [
                    'yes' => [
                        [
                            'id' => 'tag_engaged',
                            'type' => 'update_contact',
                            'config' => [
                                'actions' => [
                                    [
                                        'type' => 'add_to_segment',
                                        'segment_id' => '019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c',
                                    ],
                                ],
                            ],
                        ],
                    ],
                    'no' => [
                        [
                            'id' => 'wait_morning',
                            'type' => 'wait',
                            'config' => [
                                'mode' => 'until',
                                'time' => '09:00',
                                'weekdays' => [1, 2, 3, 4, 5],
                                'timezone' => 'America/Sao_Paulo',
                            ],
                        ],
                        [
                            'id' => 'reminder',
                            'type' => 'send_email',
                            'config' => [
                                'template_id' => 'trial-help',
                                'subject' => '{{contact.first_name}}, can I help with your trial?',
                            ],
                        ],
                        [
                            'id' => 'notify_crm',
                            'type' => 'webhook',
                            'config' => [
                                'url' => 'https://crm.acmestore.com/hooks/postal',
                            ],
                        ],
                    ],
                ],
            ],
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
201Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "draft",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": null,
  "runs_this_month": 0,
  "runs_in_progress": 0,
  "activated_at": null,
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-14T19:22:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}
422Error response
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "A condition must be the last step of its path: continue in its branches.",
  "errors": [
    {
      "step_id": "check_opened",
      "message": "A condition must be the last step of its path: continue in its branches."
    }
  ]
}

#Get an automation

GET/automations/{id}

Full-access key

Returns the automation with issues (what blocks activation, each with step_id and message) and the webhook_signing_secret.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /automations/{id}

cURL

curl https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "active",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": 2,
  "runs_this_month": 318,
  "runs_in_progress": 41,
  "activated_at": "2026-10-15T13:00:00.000000Z",
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-20T10:31:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}

#Update an automation

PATCH/automations/{id}

Full-access key

Only the fields you send change; trigger and steps replace the previous ones entirely. On a draft or a paused automation, the definition can be incomplete. On an active automation (or one with a scheduled activation), it has to be complete, and a change to an active one becomes a new version, which applies only to new runs. It returns the full automation.

Path parameters

  • idstringrequired
    The automation ID.

Request body (all optional)

  • namestring
    New name.
  • triggerobject
    New trigger.
  • stepsobject[]
    New list of steps.
  • reentrystring
    once or every_time.

Errors

  • 422validation_error
    A definition with errors or, on an active automation, one that is incomplete or changes the split of an experiment. See errors.
  • 403plan_limit_reached
    The definition has an experiment and the plan does not include A/B tests.
  • 403paused_by_plan
    The automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /automations/{id}

cURL

curl -X PATCH https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reentry": "every_time"
  }'

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    reentry: 'every_time',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "reentry": "every_time",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'reentry' => 'every_time',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "active",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "every_time",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": 3,
  "runs_this_month": 318,
  "runs_in_progress": 41,
  "activated_at": "2026-10-15T13:00:00.000000Z",
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-24T16:30:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}

#Delete an automation

DELETE/automations/{id}

Full-access key

Deletes the automation along with its versions and runs; the ones in progress stop. To only interrupt it, pause it instead.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /automations/{id}

cURL

curl -X DELETE https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.delete(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->delete('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "deleted": true
}

#Activate

POST/automations/{id}/activate

Full-access key

Activates a draft or resumes a paused automation. The definition has to be complete: templates chosen and published, a sender on a verified domain, condition paths with at least one step, webhook URLs… It publishes the definition as a version (if it changed since the last one) and returns the automation.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 422validation_error
    The definition has errors or is still incomplete. errors lists everything that is missing.
  • 403plan_limit_reached
    The automation has an experiment and the plan does not include A/B tests.
  • 403paused_by_plan
    The automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /automations/{id}/activate

cURL

curl -X POST https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/activate \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/activate', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/activate",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/activate', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "active",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": 2,
  "runs_this_month": 318,
  "runs_in_progress": 41,
  "activated_at": "2026-10-15T13:00:00.000000Z",
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-20T10:31:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}
422Error response
{
  "statusCode": 422,
  "name": "validation_error",
  "message": "The automation is not complete yet. Choose the template to send.",
  "errors": [
    {
      "step_id": "welcome",
      "message": "Choose the template to send."
    },
    {
      "step_id": null,
      "message": "Choose the event that starts the automation."
    }
  ]
}

#Pause

POST/automations/{id}/pause

Full-access key

Stops starting runs. The ones that are waiting stay on hold and continue from where they were when the automation is activated again; a wait that expired in the meantime proceeds right after.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 422validation_error
    The automation is not active.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /automations/{id}/pause

cURL

curl -X POST https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/pause \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/pause', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/pause",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/pause', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "paused",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": 2,
  "runs_this_month": 318,
  "runs_in_progress": 41,
  "activated_at": "2026-10-15T13:00:00.000000Z",
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-24T16:35:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}

#Schedule activation or pause

POST/automations/{id}/schedule

Full-access key

Schedules when the automation is activated (activate_at) and/or paused (deactivate_at), useful for a campaign with a start and an end. Send at least one of the two; a field you leave out keeps its current value, and null clears it. The times are applied every minute, and each goes back to null once applied. It returns the full automation.

An activation can only be scheduled for an automation that is not active yet and is complete (the same requirement as activating; if it stops being complete before the time, the activation is discarded and the automation stays as it is). A pause needs an active automation or an activation scheduled before it.

Path parameters

  • idstringrequired
    The automation ID.

Request body

  • activate_atstring | null
    When to activate, in ISO 8601 with a time zone, such as 2026-11-02T09:00:00-03:00. In the future and up to one year ahead.
  • deactivate_atstring | null
    When to pause, in the same format. It has to be after activate_at if both are sent.

Errors

  • 422validation_error
    Neither field sent, a time that is not a date in the future or is more than a year away, deactivate_at before activate_at, activation of an automation that is already active, a pause with no activation on an automation that is not active, or an incomplete definition (errors lists what is missing).
  • 403plan_limit_reached
    The automation has an experiment and the plan does not include A/B tests.
  • 403paused_by_plan
    The automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /automations/{id}/schedule

cURL

curl -X POST https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "activate_at": "2026-11-02T09:00:00-03:00",
    "deactivate_at": "2026-11-09T23:59:00-03:00"
  }'

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    activate_at: '2026-11-02T09:00:00-03:00',
    deactivate_at: '2026-11-09T23:59:00-03:00',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "activate_at": "2026-11-02T09:00:00-03:00",
        "deactivate_at": "2026-11-09T23:59:00-03:00",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'activate_at' => '2026-11-02T09:00:00-03:00',
        'deactivate_at' => '2026-11-09T23:59:00-03:00',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "draft",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": null,
  "runs_this_month": 0,
  "runs_in_progress": 0,
  "activated_at": null,
  "activate_at": "2026-11-02T12:00:00.000000Z",
  "deactivate_at": "2026-11-10T02:59:00.000000Z",
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-24T16:40:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}

#Clear the schedule

DELETE/automations/{id}/schedule

Full-access key

Clears the scheduled activation and pause (activate_at and deactivate_at go back to null). The status does not change. It returns the full automation.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /automations/{id}/schedule

cURL

curl -X DELETE https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.delete(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->delete('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/schedule', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation",
  "id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "paused_by_plan": false,
  "name": "Free trial welcome",
  "status": "draft",
  "trigger": {
    "type": "event",
    "event": "trial.started",
    "filters": [
      {
        "key": "plan",
        "operator": "equals",
        "value": "pro"
      }
    ]
  },
  "reentry": "once",
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "version": null,
  "runs_this_month": 0,
  "runs_in_progress": 0,
  "activated_at": null,
  "activate_at": null,
  "deactivate_at": null,
  "created_at": "2026-10-14T19:22:00.000000Z",
  "updated_at": "2026-10-24T16:45:00.000000Z",
  "issues": [],
  "webhook_signing_secret": "whsec_YXV0b21hY2FvLWV4ZW1wbG8tYWJjZGVmMTIzNA==",
  "experiments_included": true
}

#Experiment results

GET/automations/{id}/experiments

Full-access key

Each experiment step of the current definition, with the winner (if there is one) and, per variant and for the control: entered (contacts that entered), emails_sent (emails sent after the split) and how many of those contacts opened (opened, which includes anyone who clicked or replied), clicked (clicked), replied (replied) and converted (converted, with goal_event). Each contact counts once per metric.

Path parameters

  • idstringrequired
    The automation ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /automations/{id}/experiments

cURL

curl https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "data": [
    {
      "step_id": "subject_test",
      "goal_event": "order.paid",
      "winner": null,
      "variants": [
        {
          "key": "a",
          "name": "Short subject",
          "control": false,
          "percent": 45,
          "entered": 412,
          "emails_sent": 412,
          "opened": 187,
          "clicked": 41,
          "replied": 3,
          "converted": 22
        },
        {
          "key": "b",
          "name": "Subject with the name",
          "control": false,
          "percent": 45,
          "entered": 398,
          "emails_sent": 398,
          "opened": 221,
          "clicked": 58,
          "replied": 5,
          "converted": 31
        },
        {
          "key": "control",
          "name": null,
          "control": true,
          "percent": 10,
          "entered": 93,
          "emails_sent": 0,
          "opened": 0,
          "clicked": 0,
          "replied": 0,
          "converted": 4
        }
      ]
    }
  ]
}

#Declare the winner

POST/automations/{id}/experiments/{step_id}/winner

Full-access key

All contacts that reach the step from now on go to the chosen variant (the control cannot be the winner). Contacts that already went through stay on their variant. Declaring again replaces the winner. It returns the experiment results.

Path parameters

  • idstringrequired
    The automation ID.
  • step_idstringrequired
    The id of the experiment step.

Request body

  • variantstringrequired
    The variant key, such as b.

Errors

  • 422validation_error
    The key does not belong to a variant of the experiment.
  • 403paused_by_plan
    The automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /automations/{id}/experiments/{step_id}/winner

cURL

curl -X POST https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments/subject_test/winner \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "variant": "b"
  }'

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments/subject_test/winner', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    variant: 'b',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments/subject_test/winner",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "variant": "b",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/experiments/subject_test/winner', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'variant' => 'b',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation_experiment",
  "step_id": "subject_test",
  "goal_event": "order.paid",
  "winner": {
    "variant": "b",
    "declared_at": "2026-10-30T15:04:00Z",
    "declared_by": "Jane Doe"
  },
  "variants": [
    {
      "key": "a",
      "name": "Short subject",
      "control": false,
      "percent": 45,
      "entered": 412,
      "emails_sent": 412,
      "opened": 187,
      "clicked": 41,
      "replied": 3,
      "converted": 22
    },
    {
      "key": "b",
      "name": "Subject with the name",
      "control": false,
      "percent": 45,
      "entered": 398,
      "emails_sent": 398,
      "opened": 221,
      "clicked": 58,
      "replied": 5,
      "converted": 31
    },
    {
      "key": "control",
      "name": null,
      "control": true,
      "percent": 10,
      "entered": 93,
      "emails_sent": 0,
      "opened": 0,
      "clicked": 0,
      "replied": 0,
      "converted": 4
    }
  ]
}

#Runs

StatusWhat it means
runningRunning steps right now.
waitingWaiting until next_run_at: a wait step, a wait_for (which wakes earlier if the action happens) or a webhook retry. current_step_id is the next step.
completedReached the end of a path or an exit.
failedA step failed; the reason is in error and in the step log.
canceledCanceled from the dashboard or the API.
skippedDid not start: the plan's monthly run limit had been reached.

Each step that ran is recorded in steps, with its status (running, waiting, completed, failed or skipped), attempts, error and an output (output): {"until"} for a wait, {"email_id", "to", "subject"} for a send, {"result": "yes"} for a condition, {"variant": "b", "reason": "split"} for an experiment (reason: split, winner or plan), {"changes": […]} for a contact change and {"status_code"} for a webhook. A wait_for keeps {"email_id", "activity", "deadline", "resolved_by"} (see Waiting for an email action), and a send with detect_replies also carries the reply_address. Steps that pick a path (condition, wait_for, experiment) also carry result, the path followed.

The run's trigger says what started it: always the type and, depending on the trigger, event_id, event and properties (event); segment_id; topic_id; tag_id; form_id and form_submission_id; email_id, template_id, broadcast_id and link (open and click); or property, from and to (property change).

#List runs

GET/automations/{id}/runs

Full-access key

The automation's runs, from newest to oldest, with the definition of the version each one follows (definition). The step log only comes with get a run.

Path parameters

  • idstringrequired
    The automation ID.

Query parameters

  • limitinteger
    1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • statusstring
    One or more statuses separated by commas, such as running,waiting.
  • contactstring
    ID or email of the contact.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
GET /automations/{id}/runs

cURL

curl 'https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/runs?status=waiting,failed' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/runs?status=waiting,failed', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/runs",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "status": "waiting,failed",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('automations/019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f/runs', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'status' => 'waiting,failed',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response (definition shortened)
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "automation_run",
      "id": "019a1f70-7182-7d94-9fa0-1b2c3d4e5f60",
      "automation_id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
      "version": 2,
      "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "contact_email": "[email protected]",
      "status": "waiting",
      "trigger": {
        "type": "event",
        "event_id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
        "event": "trial.started",
        "properties": {
          "plan": "pro",
          "source": "website",
          "trial_days": 14
        }
      },
      "current_step_id": "check_opened",
      "next_run_at": "2026-10-26T14:20:02.000000Z",
      "error": null,
      "started_at": "2026-10-24T14:20:01.000000Z",
      "finished_at": null,
      "created_at": "2026-10-24T14:20:01.000000Z",
      "definition": [
        {
          "id": "welcome",
          "…": "…"
        }
      ]
    }
  ]
}

#Get a run

GET/automation-runs/{id}

Full-access key

The run with what started it (trigger: the type and, depending on the trigger, the event with its properties, the segment or the form), the definition of the version and the log of each step.

Path parameters

  • idstringrequired
    The run ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /automation-runs/{id}

cURL

curl https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60 \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation_run",
  "id": "019a1f70-7182-7d94-9fa0-1b2c3d4e5f60",
  "automation_id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "automation_name": "Free trial welcome",
  "version": 2,
  "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "contact_email": "[email protected]",
  "status": "waiting",
  "trigger": {
    "type": "event",
    "event_id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
    "event": "trial.started",
    "properties": {
      "plan": "pro",
      "source": "website",
      "trial_days": 14
    }
  },
  "current_step_id": "check_opened",
  "next_run_at": "2026-10-26T14:20:02.000000Z",
  "error": null,
  "started_at": "2026-10-24T14:20:01.000000Z",
  "finished_at": null,
  "created_at": "2026-10-24T14:20:01.000000Z",
  "definition": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "steps": [
    {
      "object": "automation_run_step",
      "id": "019a1f70-7183-7ea5-8ab1-2c3d4e5f6071",
      "step_id": "welcome",
      "type": "send_email",
      "status": "completed",
      "attempts": 1,
      "output": {
        "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
        "to": "[email protected]",
        "subject": "Jane, your Pro plan trial has started"
      },
      "error": null,
      "started_at": "2026-10-24T14:20:01.000000Z",
      "finished_at": "2026-10-24T14:20:02.000000Z"
    },
    {
      "object": "automation_run_step",
      "id": "019a1f70-7184-7fb6-9bc2-3d4e5f607182",
      "step_id": "wait_2_days",
      "type": "wait",
      "status": "waiting",
      "attempts": 1,
      "output": {
        "until": "2026-10-26T14:20:02Z"
      },
      "error": null,
      "started_at": "2026-10-24T14:20:02.000000Z",
      "finished_at": null
    }
  ]
}

#Cancel a run

POST/automation-runs/{id}/cancel

Full-access key

Stops a run that is in progress or waiting; the pending wait becomes skipped. It returns the run.

Path parameters

  • idstringrequired
    The run ID.

Errors

  • 422validation_error
    The run has already finished.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /automation-runs/{id}/cancel

cURL

curl -X POST https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60/cancel \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60/cancel', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60/cancel",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('automation-runs/019a1f70-7182-7d94-9fa0-1b2c3d4e5f60/cancel', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "automation_run",
  "id": "019a1f70-7182-7d94-9fa0-1b2c3d4e5f60",
  "automation_id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
  "automation_name": "Free trial welcome",
  "version": 2,
  "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "contact_email": "[email protected]",
  "status": "canceled",
  "trigger": {
    "type": "event",
    "event_id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
    "event": "trial.started",
    "properties": {
      "plan": "pro",
      "source": "website",
      "trial_days": 14
    }
  },
  "current_step_id": "check_opened",
  "next_run_at": null,
  "error": "Canceled from the dashboard or API.",
  "started_at": "2026-10-24T14:20:01.000000Z",
  "finished_at": "2026-10-25T09:12:00.000000Z",
  "created_at": "2026-10-24T14:20:01.000000Z",
  "definition": [
    {
      "id": "welcome",
      "type": "send_email",
      "config": {
        "template_id": "trial-welcome",
        "from": "Jane from Acme Store <[email protected]>",
        "subject": null,
        "reply_to": null,
        "variables": {
          "DAYS": "{{event.trial_days}}"
        }
      }
    },
    {
      "id": "wait_2_days",
      "type": "wait",
      "config": {
        "mode": "duration",
        "amount": 2,
        "unit": "days"
      }
    },
    {
      "id": "check_opened",
      "type": "condition",
      "config": {
        "kind": "email_activity",
        "step_id": "welcome",
        "activity": "opened"
      },
      "branches": {
        "yes": [
          {
            "id": "tag_engaged",
            "type": "update_contact",
            "config": {
              "actions": [
                {
                  "type": "add_to_segment",
                  "segment_id": "019a1d91-4c5d-7e6f-8a7b-8c9d0e1f2a3c"
                }
              ]
            }
          }
        ],
        "no": [
          {
            "id": "wait_morning",
            "type": "wait",
            "config": {
              "mode": "until",
              "time": "09:00",
              "weekdays": [1, 2, 3, 4, 5],
              "timezone": "America/Sao_Paulo"
            }
          },
          {
            "id": "reminder",
            "type": "send_email",
            "config": {
              "template_id": "trial-help",
              "from": null,
              "subject": "{{contact.first_name}}, can I help with your trial?",
              "reply_to": null,
              "variables": {}
            }
          },
          {
            "id": "notify_crm",
            "type": "webhook",
            "config": {
              "url": "https://crm.acmestore.com/hooks/postal"
            }
          }
        ]
      }
    }
  ],
  "steps": [
    {
      "object": "automation_run_step",
      "id": "019a1f70-7183-7ea5-8ab1-2c3d4e5f6071",
      "step_id": "welcome",
      "type": "send_email",
      "status": "completed",
      "attempts": 1,
      "output": {
        "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
        "to": "[email protected]",
        "subject": "Jane, your Pro plan trial has started"
      },
      "error": null,
      "started_at": "2026-10-24T14:20:01.000000Z",
      "finished_at": "2026-10-24T14:20:02.000000Z"
    },
    {
      "object": "automation_run_step",
      "id": "019a1f70-7184-7fb6-9bc2-3d4e5f607182",
      "step_id": "wait_2_days",
      "type": "wait",
      "status": "skipped",
      "attempts": 1,
      "output": {
        "until": "2026-10-26T14:20:02Z"
      },
      "error": null,
      "started_at": "2026-10-24T14:20:02.000000Z",
      "finished_at": "2026-10-25T09:12:00.000000Z"
    }
  ]
}

#Webhook step

The webhook step makes a POST with JSON to your URL (HTTPS and public), with the contact and the event that started the run:

automation.webhook
{
  "type": "automation.webhook",
  "created_at": "2026-10-28T12:00:03.410Z",
  "data": {
    "automation_id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
    "automation_name": "Free trial welcome",
    "run_id": "019a1f70-7182-7d94-9fa0-1b2c3d4e5f60",
    "step_id": "notify_crm",
    "trigger": "event",
    "contact": {
      "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "email": "[email protected]",
      "first_name": "Jane",
      "last_name": "Doe",
      "unsubscribed": false,
      "properties": {
        "plan": "pro",
        "city": "Austin"
      },
      "created_at": "2026-10-24T14:00:00.000Z",
      "updated_at": "2026-10-24T14:00:00.000Z"
    },
    "event": {
      "id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
      "name": "trial.started",
      "properties": {
        "plan": "pro",
        "source": "website",
        "trial_days": 14
      }
    }
  }
}

The request is signed like Postal webhooks, with the webhook-id, webhook-timestamp and webhook-signature headers (and the equivalent svix-* ones), but using the automation's webhook_signing_secret, which appears in GET /automations/{id}. The same verification code works. The webhook-id is the ID of the step log entry and repeats on retries, so you can discard duplicates. In data, trigger is the trigger type (such as event or tag.added), and event is only present when the run was started by an event (otherwise it is null).

  • Respond with 2xx within 15 seconds. Redirects are not followed and private network addresses are refused.
  • Any other response counts as a failure. There are 3 attempts: the second 1 minute later, the third 10 minutes later. After that, the step and the run become failed.
  • Deliveries use the Postal-Webhooks/1.0 User-Agent.