Sending messages
There are two ways to send: one line of text with optional headers, or a JSON body with every option.
One line of text
POST /topics/{topic} with a plain-text body (what curl -d sends) takes the body as the message, exactly as written. Optional headers set the common options:
| Header | Description |
|---|---|
| Title string | Sets title. (at most 256 characters) |
| Priority low | normal | high | critical | Sets priority: low, normal, high, critical. |
| Format text | markdown | Sets format: text or markdown. |
| Image URL | Sets image, an https URL. (at most 2048 characters) |
| Url URL | Sets url, opened from the message. (at most 2048 characters) |
| Replaces string | Sets replaces. (at most 32 characters) |
curl -H "Authorization: Bearer pba_…" \
-H "Title: Disk almost full" \
-H "Priority: high" \
-H "Url: https://grafana.example.com/d/disk" \
-d "/var is at 91% on nas-01" \
https://peskybird.app/api/push/topics/alertsThe headers are ignored when the body is JSON. Buttons, data, expiry and choosing devices need JSON.
JSON
POST /messages with Content-Type: application/json takes the topic in the body. POST /topics/{topic} takes the same JSON without topic.
curl -H "Authorization: Bearer pba_…" \
-H "Content-Type: application/json" \
-d '{
"topic": "orders",
"title": "New order",
"body": "Order #1042 · 2 items · $86.00",
"priority": "high",
"url": "https://shop.example.com/orders/1042",
"actions": [
{
"id": "open",
"title": "Open order",
"url": "https://shop.example.com/orders/1042"
},
{
"id": "ship",
"title": "Mark shipped",
"url": "https://shop.example.com/orders/1042/ship"
}
],
"data": {
"orderId": "1042"
},
"expiresInMs": 86400000
}' \
https://peskybird.app/api/push/messages| Field | Description |
|---|---|
| topicrequired string | Created on first use when the server auto-creates topics. (at most 32 characters) |
| title string | Shown above the body. At most 256 characters. (at most 256 characters) |
| bodyrequired string | The message text, required. At most 2048 characters; Markdown when format is markdown. (at most 2048 characters) |
| priority low | normal | high | critical | low, normal (default), high or critical. Each transport maps it to its own levels. (default "normal") |
| url URL | Opened on tap. (at most 2048 characters) |
| actions object[] | Up to 4 buttons shown with the notification. (at most 4 items) |
| ↳ idrequired string | Stable action identifier chosen by the publisher. (at most 32 characters) |
| ↳ titlerequired string | Button label. (at most 64 characters) |
| ↳ url URL | Opened when the action is tapped. (at most 2048 characters) |
| data object (string values) | String key-value pairs delivered with the message for the receiving app; at most 16 keys and 1024 bytes serialized. Keys starting with _ are reserved. (at most 16 keys) |
| format text | markdown | How body is rendered. Default text. A markdown body is limited to the same 2048 characters. |
| image URL | HTTPS URL of an image the phone downloads itself, at most 2048 chars. The server stores the URL and never fetches it. (at most 2048 characters) |
| expiresInMs integer | Relative TTL in milliseconds, at most 30 days; the server stores expiresAt. (more than 0; at most 2592000000) |
| devices string[] | Restrict fan-out to these device ids; they must still be subscribed to the topic. (at most 200 items) |
| replaces string | A key you choose, 1 to 32 letters, digits, ., _, : or -. A later message on the same topic with the same key takes the earlier one's place on the phone instead of adding a notification (disk-nas-01: 91%, then 95%, then resolved). (at most 32 characters) |
Priorities
low: Delivered quietly: no sound, and it waits in Notification Center.normal: The default. A regular notification with sound.high: Time-sensitive: it can break through a Focus that allows time-sensitive notifications.critical: For alerts that must not be missed. Delivered as time-sensitive unless the app has Apple’s Critical Alerts permission.
Replacing a notification
For something that changes, like a disk filling up, give each send the same replaces key (the Replaces header on a plain-text send). The new notification takes the old one's place on the phone instead of stacking under it. Keys are per topic: 1 to 32 letters, digits, ., _, : or -.
curl -H "Authorization: Bearer pba_…" \
-H "Title: Disk almost full" \
-H "Replaces: disk-nas-01" \
-d "/var is at 95% on nas-01" \
https://peskybird.app/api/push/topics/alertsRetrying safely
A script that retries after a timeout can send twice. Add an Idempotency-Key header, unique to the message (a UUID, or a job name and date). A retry with the same key within 24 hours returns the first send's result with Idempotent-Replayed: true and sends nothing. The same key with a different message gets 409 conflict.
curl -H "Authorization: Bearer pba_…" \
-H "Idempotency-Key: nightly-backup-2026-11-08" \
-d "Nightly backup completed in 4 min" \
https://peskybird.app/api/push/topics/backupsChecking before and after
- Add
?dryRun=trueto either send to validate it without sending: the response says how many devices it would reach (queued) and nothing is stored or counted toward your limits. - After a send, check it with its
messageIdto see how many devices the push service accepted it for. - List your topics to see how many devices receive each one, and list your devices to send to only some of them with
devices.
Topics and tokens
- Topic names are lowercase letters and digits, with single hyphens between words (like
home-automation), 1 to 32 characters. A send to a new name creates the topic and subscribes every paired phone to it. - A token limited to some topics gets
403 forbiddenfor any other topic.
The API reference lists every field, header and response.