Developer

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 together400. 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, not confirmDuplicate.


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 delivered field does not mean. It always carries the same value as sent and 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; use sent in new integrations. The only measured proof it reached the device is clicked (the mobile SDKs additionally report a displayed event).


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:

  1. The install must be verified (Verify install in the panel)
  2. 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'));
}

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:

This document is verified against the code by automated tests (test/api-contract.mjs) — every endpoint described here really is registered. Questions: [email protected].