Pairing and devices

A Mac joins a relay by pairing, and from then on it manages its mailbox and the list of Macs on the relay with its device token. This page documents the two pairing endpoints and the device-management endpoints: the list of Macs, pruning, removal, unpairing, and the mailbox summary, usage and status routes. Herald calls them for you from Settings > Cloud, so you need them only to implement a relay or a compatible client, or to script a setup. The routes Herald uses to receive notifications, keys and approvals are on the device side page.

Examples use $RELAY from the relay API overview and a variable for the device token.

shell
RELAY="https://herald-relay.example.workers.dev"
DEVICE_TOKEN="hrd_..."

Concepts#

Pairing is a two-step handshake. The first call asks the relay for a one-time code. The second call trades that code for the device token. Neither call needs a credential, and the relay limits them per client address.

The device token is the Mac's credential for every /v1/device/* route. It is shown once, in the reply to POST /v1/pair. The relay stores only its hash, so a lost token cannot be read back. Pair again instead.

The registry is the relay's list of paired Macs. Every Mac on a relay can read the list. A Mac can remove only the entries that cannot be in active use: one with the same name as itself, one whose credentials were rotated, or one with no connection for 7 days. It can never remove a different Mac that is active.

Endpoints#

EndpointPurpose
POST /v1/pair/startAsk the relay for a one-time pairing code.
POST /v1/pairTrade the code for the device token.
GET /v1/device/devicesList the Macs on this relay.
POST /v1/device/pruneRemove this Mac's stale siblings.
DELETE /v1/device/devices/{deviceId}Remove one entry from the list.
DELETE /v1/deviceUnpair this Mac.
GET /v1/device/infoRead a short summary of this Mac's mailbox.
GET /v1/device/usageRead today's traffic against the limits.
POST /v1/device/statusReport quiet hours and mute.

POST /v1/pair/start#

Asks the relay for a one-time pairing code. The code lasts 10 minutes and works once.

Request

NameInTypeRequiredDescription
X-Pairing-SecretheaderstringoptionalThe relay's pairing secret. Required when the relay was deployed with one.
deviceNamebodystringoptionalA name for the Mac. The relay keeps the first 60 characters.
Example requestshell
curl -s -X POST "$RELAY/v1/pair/start" \
  -H "Content-Type: application/json" \
  -H "X-Pairing-Secret: $PAIRING_SECRET" \
  -d '{"deviceName":"Studio Mac"}'

Example response

The reply is 201.

JSON
{
  "code": "K7QM-2XPD",
  "expiresInSeconds": 600
}

Response fields

FieldTypeDescription
codestringThe pairing code, 8 characters shown as XXXX-XXXX.
expiresInSecondsintegerHow long the code works. Always 600.

Errors

StatusWhen
401The relay has a pairing secret and the header is missing or wrong (unauthorized).
403The relay already has its maximum number of paired Macs (device_limit).
429Too many pairing attempts from this address or in total, or codes are already waiting (rate_limited).

Notes

  • The relay compares codes without case and without the dash.
  • A body that is empty or not JSON is treated as no body. A deviceName that is not a string is ignored.
  • Pairing again under a name that is already paired does not count against the maximum, because the new entry takes the place of the old one. The maximum is 5 Macs unless the relay's owner changes MAX_DEVICES.
  • Each client address may start 6 codes per hour and have 3 codes waiting. The relay as a whole accepts 300 starts per hour and holds 60 waiting codes. The 429 body carries retryAfterSeconds (3600, or 600 when codes are waiting) and no Retry-After header.
  • A relay's owner can change the per-address and total hourly limits with PAIR_STARTS_PER_HOUR and PAIR_STARTS_GLOBAL_PER_HOUR. See Limits and errors.

POST /v1/pair#

Trades a pairing code for the device token. After this call, the Mac holds the credential for everything under /v1/device.

Request

NameInTypeRequiredDescription
codebodystringrequiredThe code from POST /v1/pair/start.
deviceNamebodystringoptionalA name for the Mac. The relay keeps the first 60 characters. It takes precedence over the name given at the start.
Example requestshell
curl -s -X POST "$RELAY/v1/pair" \
  -H "Content-Type: application/json" \
  -d '{"code":"K7QM-2XPD","deviceName":"Studio Mac"}'

Example response

The reply is 200.

JSON
{
  "deviceId": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718",
  "deviceToken": "hrd_..."
}

Response fields

FieldTypeDescription
deviceIdstringThe Mac's id: 48 hex characters.
deviceTokenstringThe device token, hrd_<deviceId>_<secret>. It is shown once.

Errors

StatusWhen
403The code is wrong, already used or expired (bad_code). A body that is not JSON counts as a wrong code.
429This address entered 10 wrong codes in 10 minutes (rate_limited).

Notes

  • A Mac that pairs again under the same name takes the place of its earlier entry. The relay deletes the earlier entry's mailbox, keys and stored voice replies. A reinstall or a redeploy therefore leaves no dead entry behind.
  • A correct code is used up by the call. The same code again is 403 bad_code.
  • The 429 body carries retryAfterSeconds of 600 and no Retry-After header. Wrong codes from other addresses do not count against this one.

GET /v1/device/devices#

Lists the Macs paired with this relay and says which of them this Mac may remove. Use it to show the user the entries and offer to clean up the ones that are safe to remove.

Request

No parameters. The call needs the device token.

Example requestshell
curl -s "$RELAY/v1/device/devices" -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{
  "removed": [],
  "devices": [
    {
      "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718",
      "name": "Studio Mac",
      "online": true,
      "lastSeenAt": "2026-10-02T13:14:19.137Z",
      "createdAt": "2026-10-01T09:00:00.000Z",
      "thisDevice": true,
      "removable": false
    },
    {
      "id": "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978",
      "name": "Old laptop",
      "online": false,
      "lastSeenAt": "2026-09-20T08:00:00.000Z",
      "createdAt": "2026-09-01T09:00:00.000Z",
      "thisDevice": false,
      "removable": true,
      "removableReason": "idle"
    }
  ]
}

Response fields

FieldTypeDescription
removedarrayAlways empty for this call. It has the same shape as the reply of POST /v1/device/prune.
devicesarrayOne entry per paired Mac, oldest first.
devices[].idstringThe Mac's id.
devices[].namestringThe name it paired under. null when it paired without one.
devices[].onlinebooleantrue while its stream is open and it was heard from in the last 11 minutes.
devices[].lastSeenAtstringWhen it was last seen.
devices[].createdAtstringWhen it paired.
devices[].thisDevicebooleantrue for the Mac making the request.
devices[].removablebooleantrue when this Mac may remove the entry.
devices[].removableReasonstringPresent when removable is true: same_name, credentials_rotated or idle.

removableReason says why. same_name means the entry has the same name as this Mac. credentials_rotated means the relay's signing secret changed and the entry's id does not verify. idle means the entry is not online and has not been seen for 7 days. When more than one applies, the order is same_name, then credentials_rotated, then idle.

Errors

StatusWhen
404This Mac is not in the relay's registry (not_found).

POST /v1/device/prune#

Removes this Mac's own stale siblings in one call: other entries with the same name, entries whose credentials were rotated, entries whose mailbox was wiped, and entries with no connection for 30 days. It never removes this Mac, and it never removes a different Mac that is active.

Request

No parameters. The call needs the device token.

Example requestshell
curl -s -X POST "$RELAY/v1/device/prune" -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{
  "removed": ["0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978"],
  "devices": [
    {
      "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718",
      "name": "Studio Mac",
      "online": true,
      "lastSeenAt": "2026-10-02T13:14:19.137Z",
      "createdAt": "2026-10-01T09:00:00.000Z",
      "thisDevice": true,
      "removable": false
    }
  ]
}

Response fields

FieldTypeDescription
removedarrayThe ids of the entries the call removed.
devicesarrayThe Macs that remain, in the shape of GET /v1/device/devices.

Errors

StatusWhen
404This Mac is not in the relay's registry (not_found).

Notes

  • Removing an entry deletes its mailbox, its keys and its stored voice replies.
  • Prune uses 30 days for idle entries. DELETE /v1/device/devices/{deviceId} accepts an idle entry after 7 days.

DELETE /v1/device/devices/{deviceId}#

Removes one entry from the relay's list of Macs, with its mailbox. The relay refuses unless the entry has the same name as this Mac, has rotated credentials, or has been idle for 7 days. Use it when the user picks one entry to clean up.

Request

NameInTypeRequiredDescription
deviceIdpathstringrequiredThe 48-character id of the entry to remove.
Example requestshell
curl -s -X DELETE "$RELAY/v1/device/devices/0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978" \
  -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{"removed": ["0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978"]}

Errors

StatusWhen
400The id is this Mac. Use DELETE /v1/device to unpair it (invalid_request).
403The entry is a different Mac that is active (not_removable).
404No entry has that id (not_found), or this Mac is not in the registry.

Notes

  • A path segment that is not 48 hex characters does not match this route and gets 404 not_found with the message no such device endpoint.

DELETE /v1/device#

Unpairs this Mac. The relay closes the stream with code 4001, deletes the mailbox (the queue, the keys, the approvals and the subscriptions) and the stored voice replies, removes the Mac from its list, and forgets the device token. Every agent key and access token stops working.

Request

No parameters. The call needs the device token.

Example requestshell
curl -s -X DELETE "$RELAY/v1/device" -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{"unpaired": true}

GET /v1/device/info#

Returns a short summary of this Mac's mailbox: whether the Mac is connected, how many notifications wait, and the quiet-hours state it last reported.

Request

No parameters. The call needs the device token.

Example requestshell
curl -s "$RELAY/v1/device/info" -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{
  "online": true,
  "lastSeenAt": "2026-10-02T13:14:19.111Z",
  "pending": 0,
  "quietHours": {"active": false},
  "keys": 2
}

Response fields

FieldTypeDescription
onlinebooleantrue while the stream is open and the relay heard from it in the last 11 minutes.
lastSeenAtstringWhen the relay last heard from the Mac. Absent until the relay has heard from it.
pendingintegerHow many notifications wait for the Mac.
quietHoursobjectactive (boolean) and, while quiet hours are on and the Mac reported an end, until (string).
keysintegerHow many agent keys and connector approvals are active.

GET /v1/device/usage#

Returns today's traffic against this Mac's limits. The call does not count toward the request budget it reports.

Request

No parameters. The call needs the device token.

Example requestshell
curl -s "$RELAY/v1/device/usage" -H "Authorization: Bearer $DEVICE_TOKEN"
Example responseJSON
{
  "day": "2026-10-02",
  "requests": 120,
  "wsMessages": 40,
  "notifications": 18,
  "pollSeconds": 90,
  "audioUploads": 1,
  "audioBytes": 53214,
  "queued": 0,
  "storageBytes": 24576,
  "limits": {
    "audioUploadsPerDay": 40,
    "audioBytesPerDay": 20971520,
    "requestsPerDay": 5000,
    "freePlanRequestsPerDay": 100000,
    "notificationsPerDay": 500,
    "pollSecondsPerDay": 3000,
    "queueMax": 100
  },
  "requestsPercent": 2,
  "budgetExhausted": false
}

Response fields

FieldTypeDescription
daystringThe UTC date the counters cover. They reset at midnight UTC.
requestsintegerRequests that reached this mailbox today, except usage reads and stream connections.
wsMessagesintegerMessages Herald sent on the stream.
notificationsintegerNotifications queued today.
pollSecondsintegerSeconds of long polling used today.
audioUploadsintegerVoice replies uploaded today.
audioBytesintegerBytes of voice replies uploaded today.
queuedintegerNotifications waiting for the Mac.
storageBytesintegerThe size of this mailbox's storage.
limitsobjectThe limits in force for this Mac. freePlanRequestsPerDay is Cloudflare's free-plan figure, not a relay limit.
requestsPercentintegerrequests as a percent of requestsPerDay, rounded.
budgetExhaustedbooleantrue when requests has reached requestsPerDay.

Notes

  • The relay keeps the counters in memory and writes them to storage at most once a minute, so a restart can lose up to a minute of counting.
  • limits shows the defaults unless the relay's owner changed them. See Limits and errors.
  • When budgetExhausted is true, agent routes answer 503 budget_exhausted. The Mac can still connect, collect its queue and read this route.

POST /v1/device/status#

Reports the Mac's quiet-hours and mute state. It is how GET /v1/status knows whether quiet hours are on. The same information can travel as a status message on the stream. The call also marks the Mac as seen.

Request

NameInTypeRequiredDescription
quietActivebodybooleanoptionaltrue while quiet hours are on. Any other value counts as false.
quietUntilbodystringoptionalWhen quiet hours end. The relay keeps the first 40 characters.
mutedbodybooleanoptionaltrue while Herald is muted. Any other value counts as false.
Example requestshell
curl -s -X POST "$RELAY/v1/device/status" \
  -H "Authorization: Bearer $DEVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"quietActive":true,"quietUntil":"2026-10-03T07:00:00Z","muted":false}'
Example responseJSON
{"ok": true}

Errors

StatusWhen
400The body is not JSON (invalid_request).

Notes

  • Each call overwrites the whole stored state. A field you leave out is stored as false, or as no end time.
  • Agents see only quietActive and quietUntil, through GET /v1/status. The relay stores muted and does not report it.

Edit this page on GitHub

Esc
Getting started
Guides