Send events from your own systems to Pushly so journeys can react to them with notifications.
Overview
The External Events API lets your systems tell Pushly that something happened: a flight was delayed, a price crossed a threshold, an order shipped, a weather alert was issued. Each event has a name, the subscribers it concerns, and any properties you want to attach to it.
Your marketing team builds journeys in the Pushly platform that listen for an event by name. When an event arrives, every journey listening for it can:
- decide whether this particular event qualifies, using conditions on its properties (for example, only delays over 30 minutes);
- decide who receives it, either the subscribers you named or, for a broadcast event, the journey's own audience rules, including a geographic area carried on the event;
- put the event's values into the notification, for example
{{event.properties.gate}}in a title or landing URL.
You send events. The marketer decides what each event does. You do not need to know which journeys exist, and a journey can be changed without any change on your side.
Events are processed asynchronously. A successful response means Pushly has stored the event and queued it for matching. It does not mean a notification has been sent.
Endpoint
POST https://api.pushly.com/domains/{domain_id}/events
domain_id is the ID of the Pushly domain the events belong to. A request carries events for one domain only.
Authentication
The External Events API uses the same API keys as the rest of the Pushly API. Send the key in the X-API-KEY header:
X-API-KEY: {key}
The key must be an API token: either a domain API token for the domain in the path, or an organization API token with access to that domain. Admin users can create tokens in the platform. See Getting Started for where to create one.
A key that is not an API token, or one with no access to the domain in the path, is refused with 403. Nothing in the request is processed.
If request signing has been set up for your domain, every request must also be signed. See Request signing.
Quick start
The request below sends one event to two subscribers identified by your own user IDs.
curl --request POST \
--url 'https://api.pushly.com/domains/1234/events' \
--header 'X-API-KEY: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"events": [
{
"event_id": "flight-delay-UA123-2026-10-07",
"event_name": "flight_delayed",
"audience": { "external_ids": ["user-81723", "user-11904"] },
"properties": {
"flight_number": "UA123",
"delay_minutes": 45,
"gate": "B12"
}
}
]
}'// Node 18+ (built-in fetch)
const response = await fetch("https://api.pushly.com/domains/1234/events", {
method: "POST",
headers: {
"X-API-KEY": process.env.PUSHLY_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
events: [
{
event_id: "flight-delay-UA123-2026-10-07",
event_name: "flight_delayed",
audience: { external_ids: ["user-81723", "user-11904"] },
properties: { flight_number: "UA123", delay_minutes: 45, gate: "B12" },
},
],
}),
});
const result = await response.json();
for (const [eventId, outcome] of Object.entries(result.data)) {
if (outcome.error_message) console.error(eventId, outcome.status, outcome.error_message);
if (outcome.warnings) console.warn(eventId, outcome.warnings);
}import os
import requests
response = requests.post(
"https://api.pushly.com/domains/1234/events",
headers={"X-API-KEY": os.environ["PUSHLY_API_KEY"]},
json={
"events": [
{
"event_id": "flight-delay-UA123-2026-10-07",
"event_name": "flight_delayed",
"audience": {"external_ids": ["user-81723", "user-11904"]},
"properties": {"flight_number": "UA123", "delay_minutes": 45, "gate": "B12"},
}
]
},
timeout=30,
)
for event_id, outcome in response.json()["data"].items():
if "error_message" in outcome:
print("rejected", event_id, outcome["status"], outcome["error_message"])
for warning in outcome.get("warnings", []):
print("warning", event_id, warning)A successful response:
{
"status": "success",
"data": {
"flight-delay-UA123-2026-10-07": { "status": 202 }
}
}The first time Pushly sees a new event name, the response also carries a warning saying the event type has been queued for registration. That is expected. See Properties and types.
The request
The body is a JSON object with an events array and an optional trace_id.
| Field | Type | Required | Description |
|---|---|---|---|
events | array | Yes | Between 1 and 100 events. |
trace_id | string | No | Your identifier for this request, kept with every event in it so the request can be traced. One is generated if you leave it out. It is not returned in the response. |
Event fields
| Field | Type | Required | Description |
|---|---|---|---|
event_id | string | Yes | Your own unique ID for this event. It must stay the same every time you send this event, including retries. See Idempotency. |
event_name | string | Yes | What happened, for example flight_delayed. Journeys listen for an event by this name. At most 128 characters after leading and trailing whitespace and template delimiters are removed. Use a fixed set of names; never put an ID or other changing value in the name. |
audience | object | Yes | Who the event concerns. Exactly one of external_ids, pushly_ids or broadcast: true. See Audiences. |
properties | object | No | Data about the event, available to journeys for conditions and message content. Up to 50 KB. See Properties and types. |
schema_version | integer | No | The version of this event's property layout. A positive integer, default 1. Increase it when you change the shape of an event's properties in a way that would break existing journeys. A journey can be pinned to one version or hear every version. |
occurred_at | timestamp | No | When the event happened. Defaults to the time Pushly received it. Available in messages as {{event.occurred_at}}. |
expires_at | timestamp | No | When the event stops being worth a notification. See Relevance and expiry. |
priority | string | No | low, medium, high or immediate. Default medium. See Priority. |
Fields not listed here are ignored.
Timestamps are ISO 8601 strings, such as 2026-10-07T14:30:00Z or 2026-10-07T10:30:00-04:00, or a number of seconds since the Unix epoch. A string with no offset is read as UTC. Include an offset to avoid ambiguity. Milliseconds since the epoch are not supported.
Audiences
Every event must declare exactly one audience. An event that declares none, or more than one, makes the whole request fail with 400. This is deliberate: if a missing field could quietly turn into "everyone", a bug on either side could send a notification meant for one person to your whole audience.
Targeted events
Name the recipients with one kind of identifier:
| Field | Contents |
|---|---|
external_ids | Your own user IDs, as you set them on subscribers through the Pushly SDK. |
pushly_ids | Pushly IDs, as read from the Pushly SDK on the subscriber's device. |
Each must be an array of non-empty strings. Use one kind per event; to reach subscribers known by different kinds of ID, send separate events.
{
"event_id": "order-559310-shipped",
"event_name": "order_shipped",
"audience": { "external_ids": ["user-81723"] },
"occurred_at": "2026-10-07T14:02:11Z",
"properties": {
"order_id": "559310",
"carrier": "UPS",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
"items": 3,
"estimated_delivery": "2026-10-09"
}
}Identifiers are matched against the domain in the path only. An identifier that names no subscriber on that domain is skipped; the response does not report it. Duplicate identifiers within one event are sent to once.
A targeted event still goes only to subscribers a listening journey allows: the journey's own rules, such as frequency limits, still apply.
Broadcast events
Set broadcast to true to name no one. Each listening journey then uses its own audience rules to decide who receives the event.
Broadcast events suit things that are not about one person, such as a weather alert, a market move or a breaking story. A journey can combine its own rules with values from the event. For example, a journey can target subscribers whose location falls inside a geographic area that the event carries.
{
"event_id": "nws-alert-KS-2026-10-07T1430Z",
"event_name": "severe_weather_alert",
"audience": { "broadcast": true },
"priority": "high",
"occurred_at": "2026-10-07T14:30:00Z",
"expires_at": "2026-10-07T16:00:00Z",
"properties": {
"headline": "Severe thunderstorm warning",
"severity": "severe",
"area": {
"points": [
[-95.0, 39.5],
[-94.0, 39.5],
[-94.0, 38.5],
[-95.0, 38.5],
[-95.0, 39.5]
]
}
}
}Geographic areas
To let a journey target subscribers by location, send the area as an object with a points array of [longitude, latitude] pairs:
{ "points": [[-95.0, 39.5], [-94.0, 39.5], [-94.0, 38.5], [-95.0, 38.5], [-95.0, 39.5]] }- Longitude comes first, then latitude.
- At least three points are required. Close the ring by repeating the first point at the end.
- GeoJSON objects (
{"type": "Polygon", "coordinates": ...}) and bare arrays of points are not accepted for location targeting. An area in the wrong shape is still accepted at ingestion, but journeys that target on it cannot use it.
You can name the property anything (area in the example above). The marketer picks it when building the journey. Areas are stored exactly as sent, so each event that carries one gets a warning that its points property "could not be typed and was kept exactly as sent". That is expected for an area and needs no action.
Idempotency and retries
event_id is how Pushly recognizes an event it has already received. An event is identified by its domain, event_name and event_id together.
- Re-sending an event is safe. If Pushly has already accepted an event with the same name and ID, a re-send does not produce a second notification.
- Keep
event_idstable across retries. Generate it once, when the event happens in your system, and store it with the event. Do not generate a new ID per HTTP attempt. A new ID makes a new event. - The first arrival wins. A re-send cannot change an event that was already accepted. Its properties, its arrival time and its expiry stay as they were the first time. To send corrected data, send a new event with a new
event_id. - The same
event_idmay be used under different event names; those are different events.
Pushly remembers an event for at least as long as it retains it (30 days by default; see Relevance and expiry).
What to retry
| Outcome | Meaning | What to do |
|---|---|---|
Event status 202 | Accepted. | Nothing. Check warnings if present. |
Event status 413 | This event's properties are too large. It was not accepted. | Do not retry unchanged; it will fail again. Reduce its properties and send it again. The same event_id can be reused, because nothing was stored. |
Event status 500 | Pushly could not store or queue this event. It was not accepted. | Retry this event with the same event_id, with backoff. |
HTTP 400 | The request is malformed. Nothing in it was accepted. | Do not retry unchanged. Fix the request using the error_message. |
HTTP 401 | A signature is required and was missing, wrong or outside the time window. | Fix the signature or your clock, then retry. |
HTTP 403 | The API key cannot send events for this domain. | Do not retry. Check the key. |
HTTP 5xx with no per-event statuses, or no response at all (timeout, connection reset) | You cannot tell which events were accepted. | Retry the whole request unchanged, with backoff. Events that were already accepted are recognized by their event_id and are not sent twice. |
When retrying only the failed events from a partly successful request, send just those events. Re-sending the accepted ones is harmless but unnecessary.
Properties and types
properties is a JSON object of your choosing. Nested objects are allowed, and nested values are addressed with dots: {"flight": {"gate": "B12"}} becomes flight.gate, used in a message as {{event.properties.flight.gate}}.
Automatic registration
You do not need to declare event names or properties before sending them.
- The first event with a new
event_name(andschema_version) is accepted and its type is registered automatically. Registration takes a few minutes, and events sent in the meantime each carry a warning that the type "has been queued for registration". They are still accepted. - Each new property is registered with a type inferred from its first values.
- Before a marketer builds a journey on a new event, send one representative event, with every property filled in, so the event and its properties are available to choose from.
Inferred types:
| Value sent | Registered as |
|---|---|
"B12" | string |
45 | integer |
45.5 | decimal |
true | boolean |
A date-like string, such as "2026-10-09" or "2026-10-07T14:02:11Z" | timestamp |
An array whose items are all one of the types above, such as ["a", "b"] | list of that type |
| An object | not registered itself; each of its properties is registered by path |
Some values have no type that can be registered: null, an empty array, an array of mixed types, an array of objects or arrays (such as a geographic area), or objects nested more than five levels deep. These values are kept exactly as sent and can still be used in messages and as a journey's geographic area. They cannot be used in a journey condition of their own, and the response warns that the property "could not be typed".
Once a type is registered
A registered property keeps its type. Later values are converted to it where possible, for example "45" to 45 for an integer property. A value that cannot be converted is dropped from that event, and the event is still accepted with a warning naming the property.
A property registered as a number is never changed to a string. If one batch sends both 45 and "n/a" for a new property, it is registered as a number and "n/a" is dropped, with a warning. Send numbers consistently, and leave a property out rather than sending a placeholder string.
To change a property's type or the overall shape of an event, increase schema_version. Journeys pinned to the old version keep working.
Limits on registration
| Limit | Value | When exceeded |
|---|---|---|
| Event types per domain | 100 | New event names are still accepted, but not registered. Warning returned. |
| Registered properties per event type | 100 | Extra properties are still carried on the event, but not registered. Warning returned. |
| Property path length | 128 characters | The property is still carried, but not registered. Warning returned. |
Pushly can raise the event-type limit for a domain.
Template delimiters are removed
Notification content in Pushly is rendered with Liquid templates. So that event data can never be interpreted as template code, the delimiters {{, }}, {%, %} and -%} are removed from:
event_name;- every property key, at any depth;
- every string property value, at any depth, including strings inside arrays.
For example, "Hello {{ name }}" is stored as "Hello name ". No warning is returned. If your data legitimately contains these character pairs, they will not reach the notification.
A key that is empty after removal is dropped.
Relevance and expiry
Some events stop being useful quickly: a gate change is worthless after the flight leaves. Set expires_at to the moment the event should stop producing notifications.
- An event that arrives already expired is accepted with status
202and a warning saying it expired and was not sent. Its arrival is recorded, but it triggers nothing. - An event that expires while it is being processed is dropped at that point. Pushly checks expiry again before a notification is built.
- If you leave
expires_atout, the event stays relevant for 24 hours after it arrives, unless Pushly has configured a different window for your domain or event type. - Re-sending an event does not extend its expiry. The expiry from the first arrival applies.
Separately, Pushly retains each event's properties so that later steps of a journey can still use them in messages. Retention is 30 days by default and can be configured up to two years. An event is never relevant for longer than it is retained, so an expires_at beyond the retention period is shortened to it.
Priority
priority asks for faster processing relative to your other events:
| Value | Use for |
|---|---|
immediate | Time-critical alerts. |
high | Events that should be sent quickly. |
medium | The default. |
low | Events that can wait behind others. |
Priority is a request. Each event type has a ceiling, high unless Pushly has configured otherwise, and a higher request is lowered to it. A value outside this list fails the whole request with 400. Values are lower-case.
Limits
| Limit | Value | When exceeded |
|---|---|---|
| Request body | 1 MB (1,048,576 bytes) | Whole request refused with 400. |
| Events per request | 100 | Whole request refused with 400. |
event_name length | 128 characters | Whole request refused with 400. |
properties size per event | 50 KB (51,200 bytes) | That event refused with status 413. Other events in the request are unaffected. |
The properties size is measured on the properties as stored, written as compact JSON in which non-ASCII characters are escaped as \uXXXX (6 bytes each). Text in non-Latin scripts therefore counts for more than its UTF-8 size. Leave headroom.
The response
Every response that reaches the API has the same shape:
{
"status": "success",
"data": {
"<event_id>": {
"status": 202,
"error_message": "...",
"warnings": ["..."]
}
}
}| Field | Description |
|---|---|
status | success if at least one event was accepted, error if none was. |
data | One entry per event, keyed by event_id, in the order the events were sent. |
data.*.status | The HTTP status that event would have received on its own: 202, 413 or 500. |
data.*.error_message | Present only when the event was not accepted. Says why. |
data.*.warnings | Present only when the event was accepted but something about it needs your attention: a property dropped or not registered, an expired event, a new event type. |
Once the request has been read, the HTTP status is 202 even if no event in it was accepted. Always read the per-event statuses. Treat the presence of error_message as the failure signal.
If two events in one request share an event_id under different event names, the second is keyed event_name/event_id so that neither result replaces the other.
A response with failures and warnings:
{
"status": "success",
"data": {
"evt-1001": { "status": 202 },
"evt-1002": {
"status": 202,
"warnings": [
"Property 'delay_minutes' kept its numeric type; values that are not numbers were dropped."
]
},
"evt-1003": {
"status": 413,
"error_message": "Event properties are 61440 bytes, over the 51200 byte limit."
},
"evt-1004": {
"status": 500,
"error_message": "The event could not be stored. It was not queued; send it again."
}
}
}Whole-request errors
When the request is refused before any event is considered, data has a single entry under the reserved key _batch, and the HTTP status matches it:
{
"status": "error",
"data": {
"_batch": {
"status": 400,
"error_message": "Event 'flight_delayed' at position 0 carries no event_id. Supply a string that stays the same when the event is sent again."
}
}
}| HTTP status | Cause |
|---|---|
400 | The body is empty, over 1 MB, not valid JSON or not an object; events is missing, empty or over 100; an event is not an object or is missing event_id, event_name or a valid audience; event_name is over 128 characters; properties is not an object; schema_version is not a positive integer; priority is not a recognised value; occurred_at or expires_at is not a timestamp; or the domain ID in the path is not a number. |
401 | Request signing is set up for the domain and the signature is missing, malformed, outside the five-minute window or does not match. |
403 | The API key is not an API token, or has no access to this domain. |
404 | The domain was not found. |
Errors in the envelope reject the whole request: one bad event means none are accepted. Problems with a property value only ever affect that event, and are reported as warnings.
Unexpected server errors return HTTP 500 with a different body, and no per-event results:
{
"status": "error",
"error_type": "internal_server_error",
"status_code": 500,
"message": "An unexpected error occurred during your request."
}Retry these as described in What to retry. A request with a missing or invalid API key may be refused before it reaches the API, with a body that has neither shape.
Request signing
Request signing lets Pushly confirm that a request came from you and was not altered in transit. It is optional, and is enforced only for domains where a signing secret has been set up.
Not yet self-serviceSigning secrets are set up by Pushly. Contact your Pushly account manager to enable request signing for a domain. Once a secret is set up, every request for that domain must be signed. Unsigned requests are refused with
401.
To sign a request:
- Take the current time as a Unix timestamp in seconds.
- Build the string
{timestamp}.{body}, wherebodyis the exact bytes you are sending as the request body. - Compute an HMAC-SHA256 of that string using your signing secret as the key, and hex-encode it in lower case.
- Send it in the
X-Pushly-Signatureheader:
X-Pushly-Signature: t=1791383400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The timestamp must be within five minutes of Pushly's clock, in either direction, so keep your servers' clocks synchronised. During a secret rotation you may send more than one signature, t=...,v1=<sig1>,v1=<sig2>. The request is accepted if any of them matches.
Sign the body after serializing it, and send exactly those bytes. Serializing again, or letting your HTTP client re-encode the JSON, changes whitespace or key order and the signature will not match.
import { createHmac } from "node:crypto";
const body = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = createHmac("sha256", process.env.PUSHLY_SIGNING_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
await fetch("https://api.pushly.com/domains/1234/events", {
method: "POST",
headers: {
"X-API-KEY": process.env.PUSHLY_API_KEY,
"Content-Type": "application/json",
"X-Pushly-Signature": `t=${timestamp},v1=${signature}`,
},
body,
});import hashlib
import hmac
import json
import os
import time
import requests
body = json.dumps(payload, separators=(",", ":")).encode()
timestamp = int(time.time())
signature = hmac.new(
os.environ["PUSHLY_SIGNING_SECRET"].encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
requests.post(
"https://api.pushly.com/domains/1234/events",
headers={
"X-API-KEY": os.environ["PUSHLY_API_KEY"],
"Content-Type": "application/json",
"X-Pushly-Signature": f"t={timestamp},v1={signature}",
},
data=body, # send the signed bytes, not json=
timeout=30,
)BODY='{"events":[{"event_id":"evt-1","event_name":"flight_delayed","audience":{"broadcast":true}}]}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$PUSHLY_SIGNING_SECRET" -hex | sed 's/^.* //')
curl --request POST \
--url 'https://api.pushly.com/domains/1234/events' \
--header "X-API-KEY: $PUSHLY_API_KEY" \
--header 'Content-Type: application/json' \
--header "X-Pushly-Signature: t=$TS,v1=$SIG" \
--data "$BODY"Best practices
- Generate
event_idfrom your own data, for example the order number and the state it moved to, so the same real-world event always produces the same ID. - Batch events. Up to 100 events per request is more efficient than one request per event.
- Read every per-event status. A
202HTTP response can still contain events that were not accepted. - Log warnings. They are how you find out that a property was dropped or not registered.
- Set
expires_atfor anything time-sensitive, so a delayed event cannot produce a stale notification. - Keep event names and property types stable. Journeys are built on them.