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:

HeaderDescription
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/alerts

The 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
FieldDescription
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/alerts

Retrying 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/backups

Checking before and after

  • Add ?dryRun=true to 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 messageId to 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 forbidden for any other topic.

The API reference lists every field, header and response.