Events API: track events to trigger automations
Send what happens in your product to start automations and personalize emails.
Events tell Postal what happens in your product: a free trial that started, a paid order, an abandoned cart. Each event can be linked to a contact and triggers the automations whose trigger has that name. To send events, a sending-only key is enough, the same one your backend uses; to query them, you need a full-access key. Events are stored for 90 days.
The event can also create or update the contact (by ID, email or external_id, with contact_properties) before it starts the automations, so a single POST /events is enough to register the person and react to what they did. The Node.js and PHP SDKs (events.send) and the CLI (postal events send) send events.
#Send an event
POST/events
Records the event and, if it has a contact, starts the active automations whose trigger is this event (and whose filters match the properties). In contact, an email that is not in the audience yet creates the contact, which triggers the contact.created webhook and the “contact created” automations. Without contact, the event is recorded but does not start automations.
An address on the suppression list never becomes a contact: the response is 422 email_suppressed and the event is not recorded. With contact_properties, the contact is updated before the automations start, so contact.property_changed automations and event automations see the new values.
Accepts Idempotency-Key: repeating the request with the same key within 24 hours returns the same response, without recording the event again or starting another run.
Headers
Idempotency-KeystringA unique identifier of the event in your system, up to 256 characters, such astrial-8812. Recommended.X-Postal-SourcestringLabel of where the change to the contact came from (seesourcebelow).
Request body
namestringrequiredEvent name, up to 100 characters: letters, numbers,.,_,:and-, starting with a letter or number. Examples:trial.started,order_paid,checkout:abandoned.contactstring | objectID or email of the contact. An unknown email creates the contact; an unknown ID returns404. It also acceptsexternal_id:<id>(oruser_id:<id>), to find the contact by its ID in your system, or an object.idstringThe contact ID.emailstringThe contact's email. Creates the contact if it does not exist.external_idstringThe person's ID in your system, up to 255 characters. Alias:user_id.first_namestringFirst name.last_namestringLast name.
external_idstringShortcut forcontact.external_id, up to 255 characters (alias:user_id). The contact is looked up by ID, then byexternal_id, then by email; anexternal_idwith no matching contact and no email returns422.contact_propertiesobjectContact properties to update, by key, such as{"plan": "pro"}. The property must exist in contact properties and the value follows its type; an empty value removes the property.sourcestringLabel of where the change to the contact came from, up to 64 characters (lowercase letters, numbers,.,_,:and-), such asmy-app. It goes in thesourcefield of thecontact.*webhooks. It can also go in theX-Postal-Sourceheader; if both are sent, the body wins. Postal's own labels (such asdashboard,form,importandautomation) and any starting withcrm:are reserved.propertiesobjectEvent data, as JSON, up to 16 KB. It is available in the automations' filters and conditions and as variables in emails ({{event.plan}},{{event.items.0.name}}).occurred_atstringWhen it happened, in ISO 8601. Default: now. It cannot be more than 24 hours in the future.
Errors
- 422
validation_errorMissing or invalid name,propertiesthat is not an object or is over 16 KB,contactthat is neither an ID nor an email,occurred_atin the future, an invalid or reservedsource,contact_propertieswith a property that does not exist or a value of the wrong type, or anexternal_idwith no matching contact and no email. - 422
email_suppressedThe email is on the suppression list and cannot become a contact. Remove it from there first. - 404
not_foundNo contact has the ID given incontact. - 403
plan_limit_reachedThe contact would be created, but the plan does not allow more contacts. - 409
invalid_idempotent_requestThe sameIdempotency-Keywas used with a different body.
cURL
curl -X POST https://api.postal.so/events \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Idempotency-Key: trial-8812" \
-H "Content-Type: application/json" \
-d '{
"name": "trial.started",
"contact": "[email protected]",
"properties": {
"plan": "pro",
"source": "website",
"trial_days": 14
}
}'
Node.js
const response = await fetch('https://api.postal.so/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'trial-8812',
},
body: JSON.stringify({
name: 'trial.started',
contact: '[email protected]',
properties: {
plan: 'pro',
source: 'website',
trial_days: 14,
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/events",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Idempotency-Key": "trial-8812",
},
json={
"name": "trial.started",
"contact": "[email protected]",
"properties": {
"plan": "pro",
"source": "website",
"trial_days": 14,
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('events', [
'headers' => [
'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
'Idempotency-Key' => 'trial-8812',
],
'json' => [
'name' => 'trial.started',
'contact' => '[email protected]',
'properties' => [
'plan' => 'pro',
'source' => 'website',
'trial_days' => 14,
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "event",
"id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
{
"name": "order.paid",
"contact": {
"external_id": "user_42",
"email": "[email protected]"
},
"properties": {
"order_id": "10482",
"total": 199.9
},
"contact_properties": {
"plan": "pro"
},
"source": "my-app"
}
#List events
GET/events
From newest to oldest, for the last 90 days.
Query parameters
limitinteger1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.namestringOne or more names separated by commas, such astrial.started,order.paid.contactstringID or email of the contact.
Errors
- 422
validation_errorA field is invalid. The message says which one.
cURL
curl 'https://api.postal.so/events?name=trial.started&contact=jane%40example.com' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/events?name=trial.started&contact=jane%40example.com', {
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/events",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"name": "trial.started",
"contact": "[email protected]",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('events', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'name' => 'trial.started',
'contact' => '[email protected]',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "event",
"id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
"name": "trial.started",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"contact_email": "[email protected]",
"properties": {
"plan": "pro",
"source": "website",
"trial_days": 14
},
"occurred_at": "2026-10-24T14:20:00.000000Z",
"created_at": "2026-10-24T14:20:01.000000Z"
}
]
}
#List event names
GET/events/names
The event names the team has sent, from the most recently seen to the oldest (up to 100), with how many of each are stored. Useful for building triggers and filters.
cURL
curl https://api.postal.so/events/names \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/events/names', {
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/events/names",
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('events/names', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"name": "trial.started",
"count": 812,
"last_seen_at": "2026-10-24T14:20:01.000000Z"
},
{
"name": "order.paid",
"count": 4310,
"last_seen_at": "2026-10-24T14:02:47.000000Z"
}
]
}
#Get an event
GET/events/{id}
Path parameters
idstringrequiredThe event ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/events/019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/events/019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e', {
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/events/019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
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('events/019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "event",
"id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
"name": "trial.started",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"contact_email": "[email protected]",
"properties": {
"plan": "pro",
"source": "website",
"trial_days": 14
},
"occurred_at": "2026-10-24T14:20:00.000000Z",
"created_at": "2026-10-24T14:20:01.000000Z"
}
#Best practices
- Send from your server, never from the browser: the API key must not be exposed. A sending-only key is enough.
- Use an
Idempotency-Keyderived from the fact (order-10482-paid), not from the attempt: retrying after a timeout does not start the automation twice. - Choose stable names, in the past tense or as facts (
order.paid), and send inpropertiesonly what you will use in filters, conditions and emails. Do not send passwords, card numbers or sensitive data. - Top-level properties (
plan) automatically become template variables ({{{plan}}}and{{{PLAN}}}); nested properties are read with paths, such asitems.0.name.
cURL
# In your backend, right after the free trial starts
curl -X POST https://api.postal.so/events \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Idempotency-Key: trial-8812" \
-H "Content-Type: application/json" \
-d '{"name": "trial.started", "contact": "[email protected]", "properties": {"plan": "pro", "source": "website"}}'
Node.js
import { Postal } from 'postal-so'
const postal = new Postal() // reads POSTAL_API_KEY
// In your backend, right after the free trial starts
await postal.events.send(
{
name: 'trial.started',
contact: user.email,
properties: { plan: account.plan, source: account.source },
},
{ idempotencyKey: `trial-${account.id}` },
)
Python
import os
import requests
# In your backend, right after the free trial starts
requests.post(
"https://api.postal.so/events",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Idempotency-Key": f"trial-{account.id}",
},
json={
"name": "trial.started",
"contact": user.email,
"properties": {"plan": account.plan, "source": account.source},
},
timeout=10,
)
PHP
use Postal\Client;
$postal = new Client(); // reads POSTAL_API_KEY
// In your backend, right after the free trial starts
$postal->events->send([
'name' => 'trial.started',
'contact' => $user->email,
'properties' => ['plan' => $account->plan, 'source' => $account->source],
], 'trial-'.$account->id);