A practical guide to sending notifications from your server, reading the result, and receiving events over webhooks. The field-by-field reference for every endpoint is on a separate page: API reference. This page is "how to use it", that one is "what each field means".
#Your first send in five minutes
1. Create a secret key. In the panel, Settings → Keys → New key. Choose the secret (sk_) type and give it the push:send scope. The key is shown only once; it is not stored on the server.
2. Open the send gate. A new project has two conditions before it can send live: the install must be verified, and at least one test send must have been made. Both are done in the panel. While the gate is shut the API returns 403 and the action field says what to do.
3. Send.
curl -X POST https://api.bildirim.io/v1/push \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "BREAKING",
"body": "A one-sentence summary.",
"url": "https://yoursite.com/news/123"
}'
A 202 means "queued"; the send happens in the background. Use the campaignId in the response to query the result.
#Image dimensions
icon and image are optional, but an image with the wrong dimensions is cropped or looks blurry in the notification — and you only notice after sending:
| Field | Recommended | Minimum | Why |
|---|---|---|---|
image (large image) |
1440×720 (2:1) | 720×360 | Chrome (desktop) and Android crop to 2:1 in the expanded notification; anything smaller goes blurry on a high-density screen. |
icon (small icon) |
192×192 (square) | 96×96 | Shown in a square box; a rectangular logo gets cropped. |
Type: PNG, JPEG or WebP. The faster an image downloads the more often it is seen — an image over a few hundred KB may not arrive before the notification is dismissed on a slow connection. Images uploaded from the panel are served through cdn.bildirim.io (the upload field on the Create notification screen; 2 MB maximum).
#Keys and scopes
There are two kinds of key and they must not be confused:
pk_(public key) — for the browser SDK, visible to everyone in your site's HTML. It cannot be used with this API; it carries no send permission.sk_(secret key) — server to server. Never put it in a browser, a mobile app, or a public repository.
Each sk_ key can only do the work of the scopes it carries: push:send, campaigns:read, subscribers:read, subscribers:write, events:write. A key without a scope gets 403 insufficient_scope.
Least privilege pays off here: give a service that only sends notifications just push:send. If that key leaks, your subscriber list cannot be read.
If a key leaks, revoke it in the panel; revocation takes effect at once. To rotate, create and distribute the new key and then close the old one — a revoked key can be given a grace window during which both work.
#Idempotency — making retries safe
When you get a network error you cannot know whether the request reached the server. If you retry, you may have sent the same notification twice — and a sent notification cannot be recalled.
The answer: put an Idempotency-Key header on every send (a UUID is a good choice) and use the same key when retrying. The server does not treat a second request with the same key as a new send; it returns the first response again with an Idempotent-Replay: true header.
KEY=$(uuidgen)
# Got a network error? Retry with the same KEY — there is no second send.
curl -X POST https://api.bildirim.io/v1/push \
-H "Idempotency-Key: $KEY" ...
Three numbers and two rules to know:
- A key is kept for 24 hours; after that the same key starts a new send.
- If the same key arrives while the first request is still being processed you get
409 idempotency_in_progress. That lock clears after 120 seconds. - If you use the same key with a different body you get
409 idempotency_key_reuse— a fingerprint of the body is stored too. Every new notification needs a new key. - You can also pass the key in the body as an
idempotencyKeyfield instead of a header; if both are present the header wins. - Failed requests do not burn the key: getting a
500and retrying with the same key works.
Do not confuse this with duplicate protection: that looks at the content and can be bypassed with "confirmDuplicate": true. Sending the same URL within 24 hours, or the same title within 6 hours, returns 409 duplicate (utm_*, fbclid, gclid and the fragment are ignored when comparing URLs). Idempotency looks at the request and cannot be bypassed. On retries use Idempotency-Key, not confirmDuplicate.
#Targeting
If you give no target at all, the notification goes to every active subscriber. There are three methods and only one at a time may be used.
#To specific users
{
"title": "Your order has shipped",
"body": "Tracking number: 1234",
"externalIds": ["user-42", "user-77"]
}
The external id is assigned in the browser with Bildirim.login("user-42"). If a user has subscriptions in two browsers, it goes to both — your target is the user, not the device. Up to 2000 ids per request.
If you send personal content, turn on identity verification. The
login()call comes from the browser; unsigned, a visitor to your site could open devtools, sayBildirim.login("user-42")and receive that user's personal notification on their own device (and ids are easy to guess on most sites, being sequential or an email address). When you switch on Settings → Keys → Identity verification in the panel,externalIdis only accepted with a signature generated by your server:// on your server (a Node example) — the secret comes from the panel and NEVER goes to the browser const identityHash = crypto.createHmac('sha256', IDENTITY_SECRET).update(externalId).digest('hex');<!-- on the page --> <script>Bildirim.login("user-42", "<?= identityHash ?>")</script>While the setting is off the behaviour is as before (no signature is asked for); add the signature to every
login()call on your site before turning it on.
#With a segment rule
{
"title": "Sports news",
"body": "Today’s round-up",
"segment": {
"join": "AND",
"rules": [
{ "field": "tag", "key": "interest", "op": "eq", "value": "sport" },
{ "field": "lastSeen", "op": "gt", "value": "30" }
]
}
}
| Field | Operators | Value |
|---|---|---|
tag |
eq, neq, exists, contains |
"interest" — is the tag present; "interest:sport" — does it equal |
lang |
eq, neq |
read from the lang tag — the SDK does not write it by itself |
country |
eq, neq |
two-letter code; only filled if you send it |
browser, os |
eq, neq, contains |
filled in by the SDK at subscription time |
externalId |
eq, neq, exists |
the id assigned with login() |
lastSeen, firstSeen |
gt, lt |
a number of days; gt: "30" = within the last 30 days |
Groups can nest up to 3 levels and each group holds at most 20 rules. Without join, AND is assumed; "not": true inverts the whole group. You can also read the current list from GET /v1/segment-fields — the panel uses the same table.
Two fields tend to stay empty: country is not derived from the IP on the server, and the SDK does not write the lang tag. If you want to target on those, send the values yourself (Bildirim.setTags({ lang: 'en' }), or country in the subscribe request).
Passing externalIds and segment together returns 400. Which one won would be unpredictable, and sending to the wrong audience cannot be undone.
#Using a segment defined in the panel
A segment you saved in the panel can be used from the API too; copy the rule expression from there. Building and testing complex audiences in the panel produces fewer mistakes than writing the JSON by hand.
#Reading the result
curl https://api.bildirim.io/v1/push/CAMPAIGN_ID \
-H "Authorization: Bearer sk_..."
Needs the campaigns:read scope. The numbers update as the send moves through the queue: targeted, sent, failed and clicked.
Do not expect an immediate result: 202 only says it was queued. On large lists sending can take minutes.
#Rate limits
| What | Limit | On exceeding |
|---|---|---|
Per sk_ key (all endpoints) |
600 requests per minute | 429 key_rate_limited, retry-after: 60 |
POST /v1/subscribe, per IP |
30 requests per minute | 429 |
POST /v1/events, per IP |
120 requests per minute | 429 |
The limit is per key, not per IP: for your /v1/push calls there is a single limit of 600 per minute. If you send with the same key from several servers, they share that pool.
When you get a 429, the retry-after header says how many seconds to wait. Do not retry without waiting; use exponential backoff and keep the same Idempotency-Key on retries.
#Handling errors
The response body carries three fields: error is for machines, message is human-readable, and action says what to do.
{
"error": "domain_unverified",
"message": "The install must be verified for yoursite.com before sending.",
"action": "Complete \"Verify install\" on the Settings → Install screen."
}
Branch on error in your integration, show message to the user, and write action into your own logs too.
| Code | Meaning | What to do |
|---|---|---|
400 bad_request |
The body failed validation | The issues field says which field is wrong |
401 |
The key is missing or invalid | Check the key and the Bearer prefix |
402 send_quota_exceeded |
The monthly send quota is full | Upgrade the plan or wait for the 1st |
403 insufficient_scope |
The key lacks this scope | Create a key with the right scope in the panel |
403 domain_unverified |
The install is not verified | Verify it on the Install screen |
403 no_test_send |
No test send has been made | Use "Send test" on the notification screen |
403 project_deleted |
The project has been deleted | Restore it from Projects → Deleted |
409 duplicate |
The same content was sent recently | If deliberate, confirmDuplicate: true |
409 idempotency_in_progress |
An earlier request with the same key is still running | Wait a few seconds and retry with the same key |
409 idempotency_key_reuse |
The same key was used with a different body | Generate a new key for a new notification |
429 |
Rate limit | Wait as long as retry-after says |
A quota error never produces a partial send: if a send would exceed the quota, all of it is rejected, not half. The quota counts by UTC calendar month and resets on the 1st. You can see what is left in the panel or from GET /v1/projects/:id/usage.
#Webhooks
You register an address in the panel with Settings → Webhooks; a POST is made to it for the events you select.
| Event | When |
|---|---|
subscriber.created |
A new subscriber registers |
subscriber.clicked |
A subscriber clicks a notification |
conversion.created |
A conversion event is recorded |
campaign.done |
A campaign has finished sending |
#Always verify the signature
Your webhook address is open to the internet; without verifying the signature, anyone can send you fake events. The signature arrives in the X-Bildirim-Signature header as t=<timestamp>,v1=<hmac>, and the signed text is the triple t.deliveryId.rawBody.
import crypto from 'node:crypto';
function verify(raw, header, deliveryId, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${deliveryId}.${raw}`)
.digest('hex');
const a = Buffer.from(parts.v1, 'hex');
const b = Buffer.from(expected, 'hex');
// The time check prevents replay attacks.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}
Verify the body in its raw form: parsing it as JSON and re-serialising changes the bytes and the signature no longer matches.
#Redelivery
If your address returns an error the delivery is retried. The X-Bildirim-Delivery-Id header does not change on a retry — store that id so you never process the same event twice. If your endpoint stays down for a while the webhook is treated as dead and an alert appears in the panel.
#Deliberately absent
The following are not missing, they are deliberately absent. Do not design your integration around them:
- No scheduling from the API.
POST /v1/pushalways sends immediately. Scheduled sending exists only in the panel; to schedule from your own system, delay the request yourself. - No channel selection. A send goes to web, Android and iOS subscribers together; there is no "mobile only" option.
- No sending by template id. The content comes in the request.
- No multilingual content map. To send different text per language, create a segment per language and send separately.
- No email or SMS channel. Bildirim only sends push.
#Security notes
- Do not put an
sk_key on the client side. A mobile app is a client too. - Verify the webhook signature and check the timestamp.
- Be careful about putting personal data in notification content: notifications appear on the lock screen.
- Validate the
urlfield coming from your own server; passing user input straight through can create an open redirect.
#Full endpoint reference
The fields, validation rules and sample responses for every endpoint: API reference. That document is verified against the code by automated tests — every endpoint described in it really is registered.