1. Get a server key
Server calls carry a server key: Authorization: Bearer bt_live_.... Make one at bolrach.com/developers/keys with Push access after the app exists, because a key reaches only the Push apps that existed when it was made. Keep it on the server. It never goes in a web page or a mobile build.
2. Send to a person
curl -X POST "https://api.bolrach.com/push/v1/messages" \
-H "Authorization: Bearer $BOLRACH_PUSH_KEY" \
-H "Idempotency-Key: order-8123-shipped" \
-H "Content-Type: application/json" \
-d '{"app_id":"YOUR_APP_ID","target":{"type":"user","id":"user_8123"},
"title":"Your order shipped","body":"Track it in the app.",
"url":"https://shop.example.com/orders/8123","priority":1}'Push answers 202 with the message id and its status. Suppose the request times out and the job runner tries again with order-8123-shipped: Push recognises the key, returns the first answer and sends nothing new. If a retry carries the same key with a changed body, say a different title, it gets 409, and the bug in the retry logic surfaces before a customer sees two pushes.
Who to send to
| target | Reaches |
|---|---|
{"type":"user","id":"user_8123"} | Every device that person registered. The id is your own user id. |
{"type":"users","ids":[...]} | Up to 100 people in one call. For more, use POST /messages:send-batch or a campaign. |
{"type":"topic","id":"sku-4411"} | Everyone subscribed to a topic made with POST /apps/{id}/topics. |
{"type":"installation","id":"..."} | One device, such as your own test phone. |
{"type":"alias","alias":{"kind":"email_hash","value":"..."}} | A person found by another identifier, added with POST /apps/{id}/users/{user}/aliases. |
What happened to it
For one message, GET /messages/{id} returns where it stands overall. The trace at GET /messages/{id}/trace goes device by device: it shows Dana's iPhone opening the message while her old Android phone failed with an expired token. When a provider reports a token as gone for good, we switch that installation off, so a phone that deleted the app stops using up sends.
Webhooks
Register an HTTPS endpoint once. The signing secret appears only in this response, so save it then.
curl -X POST "https://api.bolrach.com/push/v1/webhooks" -H "Authorization: Bearer $BOLRACH_PUSH_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://api.example.com/hooks/bolrach-push",
"events":["notification.provider_accepted","notification.failed","notification.received","notification.opened"]}'| Event | When |
|---|---|
notification.provider_accepted | Google, Apple, Huawei, Amazon or the browser's push service took the message. |
notification.failed | The provider refused it. data.reason says why. |
notification.suppressed | We held it back on purpose: quiet hours, a frequency cap, the person's preferences or the free tier's monthly limit. |
notification.received / displayed | The device confirmed it arrived or was shown. |
notification.opened / clicked / actioned | The person tapped it or one of its buttons. |
* | Every event. |
Check the Bolrach-Signature header before trusting a delivery. It looks like t=1729764000,v1=5f2b.... Take the t value, add a dot and the raw body exactly as it arrived, and an HMAC-SHA256 of that with your secret should give you the v1 hex. A mismatch means the request did not come from us. So does a t more than five minutes away from the current time, which is how a replayed request gets caught. Reply with any 2xx inside ten seconds; otherwise the event comes back, up to eight more times over about two hours, carrying the same Bolrach-Event-ID, and that id is the thing to store for skipping repeats.
import crypto from "node:crypto";
app.post("/hooks/bolrach-push", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Bolrach-Signature") || "";
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1] || "";
const expected = crypto.createHmac("sha256", process.env.BOLRACH_PUSH_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!t || !fresh || v1.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(400);
const event = JSON.parse(req.body); // { id, type, created, data: { delivery_id, app_id, ... } }
res.sendStatus(204);
});Errors and retries
An error answers with a JSON body in one shape, so one handler covers every route:
{ "error": { "status": 422, "code": "validation_failed", "message": "title or body is required" } }| Status | What to do |
|---|---|
| 400, 422 | The request is wrong. Fix it; sending it again will fail the same way. |
| 401 | The key is missing, revoked or for another product. On a device call, the receipt token is wrong. |
| 403 | The key works but lacks the scope this route needs, or it was made before this app existed. |
| 409 | The Idempotency-Key was used before with a different body, or the object changed under you. Read it again. |
| 429 | Too fast for your limits. Wait for the seconds in Retry-After, then send again with the same Idempotency-Key. |
| 503 | Usually safe to retry with the same key. POST /messages may already be stored, and the key makes the retry return it. |
Listing users by hand stops at 100 ids per message. Past that, a topic or a segment is the better tool: one call does it, and we work through the ten thousand recipients on our side. Free-tier apps have a monthly allowance of people, anyone past it is held back with the reason plan_mau_limit, and GET /plan shows how much of the allowance is gone.