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
| Status | How it works |
|---|---|
draft | Created 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. |
active | After you activate it: the definition has to be complete and becomes a version (version). Each trigger starts a run. |
paused | After 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
type | Fields | When it starts | Example |
|---|---|---|---|
event | event: 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.added | segment_id | A 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.removed | segment_id | A 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.subscribed | topic_id | The 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.unsubscribed | topic_id | The opposite: the choice becomes opt_out. | {"type": "topic.unsubscribed", "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b"} |
form.submitted | form_id | Someone signs up through the form (with double opt-in, after confirming). | {"type": "form.submitted", "form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d"} |
tag.added | tag_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.opened | Optional 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.clicked | The 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_changed | property: 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.
type | What it does | config |
|---|---|---|
wait | Waits 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_for | Waits 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_email | Sends 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). |
condition | Follows 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_contact | Changes 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. |
webhook | Sends the contact and the event to a URL of yours. | {"url": "https://…"}. See Webhook step. |
experiment | Splits 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. |
exit | Ends 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
kind | Fields | Example |
|---|---|---|
contact_property | key (email, first_name, last_name or a property), operator, value | {"kind": "contact_property", "key": "plan", "operator": "equals", "value": "pro"} |
event_property | key (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} |
segment | segment_id, operator: in or not_in | {"kind": "segment", "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a", "operator": "in"} |
topic | topic_id, operator: subscribed or not_subscribed | {"kind": "topic", "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b", "operator": "subscribed"} |
tag | tag_id (or tag_name), operator: has or not_has | {"kind": "tag", "tag_id": "019a1d85-6c7d-7e8f-9a0b-1c2d3e4f5a6b", "operator": "has"} |
email_activity | step_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.
{
"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?"
}
}
]
}
}
config | What it is |
|---|---|
step_id | The id of an earlier send_email on the same path. Required. |
activity | opened (default; a click or a reply also counts), clicked, clicked_link or replied. |
link_contains | Only 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. |
repliedrequiresdetect_replies: trueon thesend_emailstep; without it, the automation cannot be activated. Automatic replies (out of office) do not count.- If the email of the
step_idwas not sent in this run (for example, the contact is unsubscribed and the send was skipped), the run followsbranches.no. - In the run log, the
outputsays how it ended:resolved_byisevent(withresolved_at),timeoutorno_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_NAMEandCONTACT_ID; - each contact property and each top-level property of the event, by key and in uppercase (
planandPLAN); 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…).
{
"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}/experimentsreturns, 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}/winnersends 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_experimentsfeature. Without it, creating, saving, activating or scheduling an automation with an experiment responds403 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 includesexperiments_included.
#List automations
GET/automations
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
limitinteger1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name.statusstringdraft,activeorpaused.scheduledbooleanWithtrue, only the automations with a scheduled activation or pause.
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);
{
"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
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
Errors
- 422
validation_errorInvalid field or a definition with errors. The extraerrorsfield lists each problem, with thestep_id(ornull, for the trigger) and the message. - 403
plan_limit_reachedThe definition has an experiment and the plan does not include A/B tests. - 403
plan_limit_reachedThe plan does not allow more automations (theautomationslimit).
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);
{
"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
}
{
"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}
Returns the automation with issues (what blocks activation, each with step_id and message) and the webhook_signing_secret.
Path parameters
idstringrequiredThe automation ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
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
idstringrequiredThe automation ID.
Request body (all optional)
namestringNew name.triggerobjectNew trigger.stepsobject[]New list of steps.reentrystringonceorevery_time.
Errors
- 422
validation_errorA definition with errors or, on an active automation, one that is incomplete or changes the split of an experiment. Seeerrors. - 403
plan_limit_reachedThe definition has an experiment and the plan does not include A/B tests. - 403
paused_by_planThe automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
Deletes the automation along with its versions and runs; the ones in progress stop. To only interrupt it, pause it instead.
Path parameters
idstringrequiredThe automation ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "automation",
"id": "019a1f60-6071-7c83-8e9f-0a1b2c3d4e5f",
"deleted": true
}
#Activate
POST/automations/{id}/activate
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
idstringrequiredThe automation ID.
Errors
- 422
validation_errorThe definition has errors or is still incomplete.errorslists everything that is missing. - 403
plan_limit_reachedThe automation has an experiment and the plan does not include A/B tests. - 403
paused_by_planThe automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
}
{
"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
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
idstringrequiredThe automation ID.
Errors
- 422
validation_errorThe automation is not active. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredThe automation ID.
Request body
activate_atstring | nullWhen to activate, in ISO 8601 with a time zone, such as2026-11-02T09:00:00-03:00. In the future and up to one year ahead.deactivate_atstring | nullWhen to pause, in the same format. It has to be afteractivate_atif both are sent.
Errors
- 422
validation_errorNeither field sent, a time that is not a date in the future or is more than a year away,deactivate_atbeforeactivate_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 (errorslists what is missing). - 403
plan_limit_reachedThe automation has an experiment and the plan does not include A/B tests. - 403
paused_by_planThe automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredThe automation ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredThe automation ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredThe automation ID.step_idstringrequiredTheidof theexperimentstep.
Request body
variantstringrequiredThe variant key, such asb.
Errors
- 422
validation_errorThe key does not belong to a variant of the experiment. - 403
paused_by_planThe automation is paused because it is above the plan's automations limit. Upgrade your plan or delete others. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
| Status | What it means |
|---|---|
running | Running steps right now. |
waiting | Waiting 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. |
completed | Reached the end of a path or an exit. |
failed | A step failed; the reason is in error and in the step log. |
canceled | Canceled from the dashboard or the API. |
skipped | Did 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
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
idstringrequiredThe automation ID.
Query parameters
limitinteger1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.statusstringOne or more statuses separated by commas, such asrunning,waiting.contactstringID or email of the contact.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
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
idstringrequiredThe run ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
Stops a run that is in progress or waiting; the pending wait becomes skipped. It returns the run.
Path parameters
idstringrequiredThe run ID.
Errors
- 422
validation_errorThe run has already finished. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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:
{
"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
2xxwithin 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.0User-Agent.