Bildirim Server API
For sending notifications from your server. Installing the browser SDK is covered on the Install screen in the panel; a quick reference is at the end of this document.
Base address: https://api.bildirim.io
Authentication
Authorization: Bearer sk_...
A secret key (sk_) is created in the panel under Settings → Keys and is
shown only once. It is not stored on the server; if it is lost, rotate it.
A public key starting with pk_ is for the browser SDK and cannot be used
with this API. The public key is visible to everyone (it sits in your site's
HTML), which is why it carries no send permission.
Scopes
A key can only do the work of the scopes it carries:
| Scope | What it allows |
|---|---|
push:send |
POST /v1/push |
campaigns:read |
GET /v1/push/:id |
subscribers:read / subscribers:write |
Subscriber endpoints |
events:write |
Reporting conversion events |
Older keys with no scope record are treated as full access; defining scopes does not narrow them retroactively.
A key without a scope gets 403 insufficient_scope.
Rate limit
When the limit is exceeded a 429 is returned with these headers:
Retry-After: 166
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 166
Retry-After is in seconds. Do not retry without waiting; use exponential
backoff and keep the same Idempotency-Key on retries.
Idempotency — making retries safe
When a send request times out you cannot know whether it reached the server. Retrying is the right thing to do; but retrying without a key means the same notification goes to the subscriber a second time, and a sent notification cannot be recalled.
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
It is also accepted in the body as "idempotencyKey": "..." (the header wins).
Behaviour:
| Case | Result |
|---|---|
| The key is seen for the first time | The request is processed normally and the response is stored |
| Same key + same body | The stored response is returned as-is with Idempotent-Replay: true. No new send is made. |
| Same key, the first request is still running | 409 idempotency_in_progress — retry in a few seconds |
| Same key + a different body | 409 idempotency_key_reuse — create a new key for each notification |
Keys are kept for 24 hours. Since retries happen within minutes, that is more than enough for the purpose.
Errors are not stored. If you get a 403 because the send gate is shut,
you can retry with the same key once the gate is open — the key is not spent
when no send was made.
POST /v1/push — send a notification
curl -X POST https://api.bildirim.io/v1/push \
-H "Authorization: Bearer sk_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"title": "BREAKING",
"body": "A one-sentence summary.",
"url": "https://yoursite.com/news/123"
}'
Content fields
| Field | Type | Required | Description |
|---|---|---|---|
title |
string (1–200) | yes | The notification title |
body |
string (1–1000) | yes | The notification text |
url |
url (≤2000) | no | The address opened on click |
icon |
url | no | The small icon. Recommended 192×192 (square), minimum 96×96 — a non-square logo is cropped. If omitted, the project's default icon is used, and failing that your site's /favicon.ico. |
image |
url | no | The large image (Chrome, Android, iOS). Recommended 1440×720 (2:1), minimum 720×360 — Chrome and Android crop to 2:1, and anything smaller looks blurry on a retina screen. |
actions |
array (1–3) | no | Action buttons: [{ "id": "read", "label": "Read", "url": "https://..." }]. id lowercase/digits/_/- (≤24), label ≤30, url optional — without it the button opens the notification's url. Chrome, Android and iOS show them; Firefox/Safari ignore them. A button click counts as a campaign click and which button was pressed is recorded. |
badge |
integer (0–9999) | no | The iOS app icon badge (aps.badge). 0 clears it. Ignored on web and Android. |
channels |
array | no | A subset of ["web","android","ios"]; all of them if omitted. Only subscribers on the selected platforms receive it. |
confirmDuplicate |
boolean | no | See below |
Targeting — choose one method
If none is given it goes to all active subscribers.
1) Specific users (externalIds, up to 2000):
{ "title": "Your order has shipped", "body": "Tracking: 1234",
"externalIds": ["user-42", "user-77"] }
The external id is assigned in the browser SDK with
Bildirim.login("user-42"). If the same user has several subscriptions (two
browsers) it goes to all of them — you want to reach the person, not a device.
2) Segment rules — any audience you can build in the panel:
{ "title": "Sports news", "body": "...",
"segment": {
"join": "AND",
"rules": [
{ "field": "tag", "key": "interest", "op": "eq", "value": "sport" },
{ "field": "lastSeen", "op": "gt", "value": "30" }
]
} }
Fields: tag, lang, country, browser, os, externalId, lastSeen,
firstSeen. The operators vary by field; the panel reads the same table from
GET /v1/segment-fields.
The older {"segment": {"tags": {"vip": true}}} form still works.
externalIds and segment cannot be given together → 400. Which one won
would be unpredictable, and sending to the wrong audience cannot be undone.
Response
{ "campaignId": "ddf0d253-...", "targeted": 2, "batches": 1, "status": "queued" }
202 = queued. If no subscriber matches, 200 with "status": "done".
Duplicate protection
If the same title/address was sent recently, 409 duplicate is returned. If you
are resending deliberately, add "confirmDuplicate": true.
This is not idempotency. Duplicate protection looks at the content and can be bypassed; idempotency looks at the request and cannot. On retries use
Idempotency-Key, notconfirmDuplicate.
GET /v1/push/:id — read the send result
Requires the campaigns:read scope.
{
"id": "ddf0d253-...", "title": "BREAKING", "body": "...",
"clickUrl": null, "status": "done",
"targeted": 2, "sent": 2, "delivered": 2, "clicked": 0,
"createdAt": "2026-08-04T19:14:01.064Z",
"sentAt": "2026-08-04T19:14:01.063Z"
}
A campaign id belonging to another project returns 404 — not 403, because
403 would reveal that the id exists.
What the
deliveredfield does not mean. It always carries the same value assentand does not mean "it reached the user": the web push standard has no delivery receipt — the push service (Google/Mozilla/Apple) reports that it has queued the notification, and what happens after that is invisible to the server. The field remains for backwards compatibility; usesentin new integrations. The only measured proof it reached the device isclicked(the mobile SDKs additionally report adisplayedevent).
Error format
{ "error": "domain_unverified",
"message": "The install must be verified for yoursite.com before sending.",
"action": "Complete \"Verify install\" on the Settings → Install screen." }
error is for machines, message for humans, and action says what to do.
| Code | Meaning |
|---|---|
400 bad_request |
The body failed validation (issues gives detail) |
401 |
The key is missing / invalid |
402 quota_exceeded |
The monthly send quota is full |
403 insufficient_scope |
The key lacks this scope |
403 domain_unverified / no_test_send |
The send gate is shut |
409 duplicate |
The same content was sent recently |
409 idempotency_* |
See the idempotency table above |
429 |
Rate limit — wait as long as Retry-After says |
The send gate
Two conditions before a new project can send live:
- The install must be verified (Verify install in the panel)
- At least one test send must have been made
While the gate is shut a 403 is returned and the action field says what to
do. The purpose is to stop a misconfigured install from sending to real users.
Outgoing webhooks
A POST is made to the address you register under Settings → Webhooks for
the events you select. The events:
| 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 |
Request headers:
Content-Type: application/json
X-Bildirim-Event: subscriber.created
X-Bildirim-Delivery-Id: 1c1f6a52-... ← the delivery id, UNCHANGED on retries
X-Bildirim-Signature: t=1722945600,v1=8f2a... ← HMAC-SHA256
User-Agent: Bildirim-Webhook/1.0
Verify the signature
The signed text is the triple t.deliveryId.rawBody; the key is the secret
shown to you once when you registered the webhook:
const crypto = require('node:crypto');
function verify(secret, rawBody, deliveryId, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; // ±5 min
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${deliveryId}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
}
tis the Unix second of the request; reject anything outside ±5 minutes (replay protection).X-Bildirim-Delivery-Idis stable across retries — that is your idempotency key; if you see the same id twice, do not repeat the work.
Delivery and retries
Any response other than 2xx counts as a failure and is retried 5 times at
exponential intervals (starting at 5 s and doubling). If all five attempts fail
the delivery is marked dead and is not retried; the delivery history can
be followed in the panel and from the delivery endpoint. A request that does not
answer within 15 seconds times out — queueing the work and returning 200
immediately is the healthiest approach.
If dead deliveries pile up, an alert email goes to the project owner.
Browser SDK — quick reference
The install is a single script (detail and verification are on the panel's Install screen):
<script src="https://cdn.bildirim.io/sdk.js" data-key="pk_..."
data-api="https://api.bildirim.io" defer></script>
plus the BildirimSDK-sw.js file, which must sit in your site's /bildirim/
folder (not at the root). data-api is required.
| Call | What it does |
|---|---|
Bildirim.subscribe() |
Asks for permission and registers the subscription with the server |
Bildirim.unsubscribe() |
Removes the subscription |
Bildirim.setTags({interest: 'sport'}) |
Adds/updates tags on the subscriber (used in segment targeting). Tags are merged: tags you do not send are kept, and a null value deletes that tag |
Bildirim.login('user-42') |
Links your own user id to the subscriber → externalIds targeting |
Bildirim.logout() |
Clears the user id |
Bildirim.track('purchase', {value: 249.9, currency: 'TRY'}) |
Reports a conversion event (revenue attribution) |
Bildirim.showTopics() |
Opens the topic preference card (if topics are defined under Settings → Topics); the choice is stored as a topic_<key> tag |
Bildirim.showIosInstall() |
Opens the "Add to Home Screen" card in iOS Safari (if the site has a manifest; returns silently in standalone mode or in a push-capable browser) |
Bildirim.isSupported() |
Tells you whether the browser is supported |
To queue calls before the SDK loads:
<script>
window.BildirimDeferred = window.BildirimDeferred || [];
window.BildirimDeferred.push(function (bildirim) {
bildirim.login('user-42');
});
</script>
Deliberate differences
Things other providers have that are deliberately absent here:
- Multilingual content maps (
{"en": "...", "tr": "..."}): needless complexity for customers publishing in one language. If you need to send by language, send a separate campaign using thelangsegment field. - Email / SMS channels: Bildirim is a web push platform. Email is used only for the product's own notifications (password reset and the like).
- Templates: campaign templates exist in the panel; sending by template id from the API does not exist yet.
- Scheduled sending from the API: it exists in the panel (
scheduledAt) but not yet in the server API.POST /v1/pushalways sends immediately.