Send from your server

We kept these samples plain on purpose. Each one is a single HTTPS request, the Node, Python and PHP versions use nothing outside their standard libraries, and for anyone who would rather call typed methods, the server SDKs send exactly the same requests.

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

targetReaches
{"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"]}'
EventWhen
notification.provider_acceptedGoogle, Apple, Huawei, Amazon or the browser's push service took the message.
notification.failedThe provider refused it. data.reason says why.
notification.suppressedWe held it back on purpose: quiet hours, a frequency cap, the person's preferences or the free tier's monthly limit.
notification.received / displayedThe device confirmed it arrived or was shown.
notification.opened / clicked / actionedThe 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" } }
StatusWhat to do
400, 422The request is wrong. Fix it; sending it again will fail the same way.
401The key is missing, revoked or for another product. On a device call, the receipt token is wrong.
403The key works but lacks the scope this route needs, or it was made before this app existed.
409The Idempotency-Key was used before with a different body, or the object changed under you. Read it again.
429Too fast for your limits. Wait for the seconds in Retry-After, then send again with the same Idempotency-Key.
503Usually 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.

Machine-readable: openapi.json. Console: bolrach.com/push.