Sign in Create account
Marketing

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

Full-access or sending-only key

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-Keystring
    A unique identifier of the event in your system, up to 256 characters, such as trial-8812. Recommended.
  • X-Postal-Sourcestring
    Label of where the change to the contact came from (see source below).

Request body

  • namestringrequired
    Event name, up to 100 characters: letters, numbers, ., _, : and -, starting with a letter or number. Examples: trial.started, order_paid, checkout:abandoned.
  • contactstring | object
    ID or email of the contact. An unknown email creates the contact; an unknown ID returns 404. It also accepts external_id:<id> (or user_id:<id>), to find the contact by its ID in your system, or an object.
    • idstring
      The contact ID.
    • emailstring
      The contact's email. Creates the contact if it does not exist.
    • external_idstring
      The person's ID in your system, up to 255 characters. Alias: user_id.
    • first_namestring
      First name.
    • last_namestring
      Last name.
  • external_idstring
    Shortcut for contact.external_id, up to 255 characters (alias: user_id). The contact is looked up by ID, then by external_id, then by email; an external_id with no matching contact and no email returns 422.
  • contact_propertiesobject
    Contact 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.
  • sourcestring
    Label of where the change to the contact came from, up to 64 characters (lowercase letters, numbers, ., _, : and -), such as my-app. It goes in the source field of the contact.* webhooks. It can also go in the X-Postal-Source header; if both are sent, the body wins. Postal's own labels (such as dashboard, form, import and automation) and any starting with crm: are reserved.
  • propertiesobject
    Event 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_atstring
    When it happened, in ISO 8601. Default: now. It cannot be more than 24 hours in the future.

Errors

  • 422validation_error
    Missing or invalid name, properties that is not an object or is over 16 KB, contact that is neither an ID nor an email, occurred_at in the future, an invalid or reserved source, contact_properties with a property that does not exist or a value of the wrong type, or an external_id with no matching contact and no email.
  • 422email_suppressed
    The email is on the suppression list and cannot become a contact. Remove it from there first.
  • 404not_found
    No contact has the ID given in contact.
  • 403plan_limit_reached
    The contact would be created, but the plan does not allow more contacts.
  • 409invalid_idempotent_request
    The same Idempotency-Key was used with a different body.
POST /events

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);
201Response
{
  "object": "event",
  "id": "019a1f40-5f60-7b72-9d8e-9f0a1b2c3d4e",
  "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
}
Another body: contact by its ID in your system, with properties
{
  "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

Full-access key

From newest to oldest, for the last 90 days.

Query parameters

  • limitinteger
    1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • namestring
    One or more names separated by commas, such as trial.started,order.paid.
  • contactstring
    ID or email of the contact.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
GET /events

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);
200Response
{
  "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

Full-access key

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.

GET /events/names

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);
200Response
{
  "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}

Full-access key

Path parameters

  • idstringrequired
    The event ID.

Errors

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

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);
200Response
{
  "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-Key derived 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 in properties only 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 as items.0.name.
From your backend

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);