Docs
Your first notification, in about ten minutes.
Bzzt is a REST API and a console. Any language that can make an HTTP request can send; any app that can read JSON can show a feed. If you are in JavaScript or TypeScript there is also a small SDK that handles the live connection for you — useful, never required.
How it works
Three moving parts, and you only write code for two of them.
- 01
You send
Your backend makes one API call: a notification type, who it is for, and the data behind it. You never send the words.
- 02
We render and deliver
We look up your template, write the sentence in each recipient’s language, put it in their feed and push it to their devices.
- 03
Your users read
Your app asks for that person’s feed and gets back both the finished text and the raw data, so you can draw it however you like.
The reason you send data rather than words is that a push notification is drawn by the phone while your app is closed. No code of yours runs at that moment, so the finished sentence has to already exist. Keeping the words with us is what lets the same call fill the feed, the phone and the browser.
Quickstart
Four steps. The first two happen in the console, the last two in your code.
- 1
Describe the notification
In the console, under Notification types, create a type. Give it a key you will use in code, like
invoice.paid, and list the data it carries.The data an invoice.paid notification carries
{ "invoice_number": "string", "amount": "string", "customer_name": "string" }We check every send against this. A typo in your backend is rejected at the API with a clear message, instead of arriving as a notification with a blank in the middle of it.
- 2
Write the words
Still on the type, write a template. One row per language, with a live preview beside it. Anything in curly braces is filled in from the data you send.
English
Title Invoice {invoice_number} is paid Body {customer_name} paid {amount}. Nothing else to do.Add the other eighteen languages whenever you like. Until then everyone gets your default one, and the console shows you exactly which are missing.
- 3
Send one
From your backend, with the API key from Settings.
recipientsholds your user ids — the ones your own database already uses.POST https://api.bzzt.now/v1/notifications
curl https://api.bzzt.now/v1/notifications \ -H "Authorization: Bearer $BZZT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "invoice.paid", "recipients": ["user_8812"], "payload": { "invoice_number": "INV-2026-0041", "amount": "€2,400", "customer_name": "Globex" } }'You get
202 Acceptedstraight away. That is not us being vague: the notification does not exist yet, and saying it does would be a lie you could catch by reading the feed a millisecond later. It lands within a second or two.Nothing built yet? Use Send a notification in the console and send the same thing by hand first. Same path, same result — see the console.
- 4
Show it in your app
Your backend mints a short-lived token for the signed-in user, your frontend fetches it, and then talks to us directly. We never see your passwords or your sessions.
Your frontend, with that token
const res = await fetch("https://api.bzzt.now/v1/me/notifications", { headers: { Authorization: `Bearer ${bzztToken}` }, }); const { items } = await res.json(); items[0]; // { // id: "nt_7c1e…", // typeKey: "invoice.paid", // content: { title: "Invoice INV-2026-0041 is paid", body: "Globex paid €2,400…" }, // data: { invoice_number: "INV-2026-0041", amount: "€2,400", … }, // createdAt: "2026-10-20T09:12:04.182Z", // seenAt: null, readAt: null, archivedAt: null // }Every item gives you both. Use
contentand you are done; usedataif you would rather build the sentence yourself in the client.In JavaScript or TypeScript, the SDK does the fetching, the live connection, reconnecting and the unread counts for you:
npm install @bzzt/client
import { NotificationClient } from "@bzzt/client"; const client = new NotificationClient({ baseUrl: "https://api.bzzt.now", // Called on every connect, not once: tokens expire, sockets outlive them. getToken: () => fetchShortLivedToken(), }); client.subscribe(({ items, counts }) => render(items, counts.unread)); await client.start();It also gives you
markRead,archive,fetchNextPageand device registration, applied optimistically so the interface responds immediately. The REST endpoints below are exactly what it calls, so you can start with one and move to the other without changing anything on your server.
Who a notification is for
The most important idea here, and the shortest. Everything else is plumbing.
We do not store your users. No names, no email addresses, no passwords. We know a person only by an id that you choose — almost always the id already in your own database.
That id shows up in exactly two places, and they have to agree.
When you send
Your backend says who the notification is for.
"recipients": [
"user_8812"
]
When your app reads
The token you signed says who is asking.
{
"sub": "user_8812"
}
Same string, same person, and the feed fills up. We compare them character for character and do nothing clever: we have no other way of knowing that two ids are the same human.
Watch out
Different strings means an empty feed, and nothing anywhere reports an error.
user_8812 on one side, auth0|6f2c… on the other.Check it in thirty seconds
Before wiring anything else: send one notification to yourself, then load your own feed. If the notification is there, the two sides agree and they will keep agreeing. If the feed is empty, print both values side by side — the problem is always visible the moment you do.
Minting the token
A few lines on an endpoint of your own. Sign it with the key you gave us and keep it short-lived.
Your backend, on a request from a signed-in user
jwt.sign(
{
sub: currentUser.id, // ← the same id you send as a recipient
iss: "https://your-app.com", // the issuer we hold for you
},
yourPrivateKey,
{ algorithm: "RS256", expiresIn: "1h" },
);Sending
One endpoint, POST /v1/notifications, authenticated with your API key. It behaves the same whether you address one person or everyone at a customer.
| Field | What it does |
|---|---|
| type | The notification type key you created in the console. Required. |
| recipients | Your user ids. A plain list of strings, or objects if you want to set a language or an inbox per person. Required. |
| payload | The data behind the notification, checked against the type. Your templates fill themselves in from this. |
| dedupeKey | Your own idempotency key. Send the same one twice and the second is ignored, so a retry after a timeout cannot notify somebody twice. |
| content | Pre-written text that skips the template, per channel. Useful while you are still translating, or for one-off messages not worth a template. |
| feedKey | Which inbox it lands in, if you have more than one. Most people never set this. |
To reach a whole company, pass every id in one call. We expand it in the background in batches, so the call returns just as fast for ten thousand people as for one, and a problem delivering to one person never holds up anybody else.
Watch out
A retry is free. A second call is not.
dedupeKey is what makes a retry safe, and it is the one field people skip and later wish they had not. Without it, the same notification sent twice is two notifications — because we cannot tell your retry from your colleague genuinely sending it again.Reading the feed
Everything under /v1/me/ is called by your app with the user’s token. The person is always taken from the token, never from the URL, so one user cannot ask for another’s feed.
| Endpoint | What it gives you |
|---|---|
| GET /v1/me/notifications | The feed, newest first. Takes limit, cursor to page back through history, and unread=true to filter. |
| GET /v1/me/notifications/counts | The unread and unseen counts. This is your bell badge. |
| POST /v1/me/notifications/seen | Mark several as seen at once, when they scroll into view. |
| POST /v1/me/notifications/:id/read | Mark one as read, when it is opened. |
| POST /v1/me/notifications/read-all | The “mark all as read” button. |
| POST /v1/me/notifications/:id/archive | Dismiss one from the list. |
Seen, read and archived are three separate things rather than one status, because they really are separate: somebody can scroll past a notification without opening it, or dismiss one they never read. Each is a timestamp, or null.
Live updates
Open a socket and new notifications arrive as they happen, without polling.
In the browser
const socket = new WebSocket(
`wss://api.bzzt.now/v1/me/stream?token=${bzztToken}`
);
socket.onmessage = (event) => {
const hint = JSON.parse(event.data);
// { id, typeKey, unreadCount, cursor }
setBadge(hint.unreadCount);
};The message is a hint, not the notification itself. Treat it as a nudge to update your badge and, if the list is on screen, to fetch what is new. Reading a notification on a phone clears the badge on an open laptop the same way.
Watch out
Sockets drop. Build for it, and it costs you nothing.
4001, which means “get a fresh token and reconnect” rather than “something broke”. The SDK already does all of this.Push notifications
Phones and browsers, from the same call that fills the feed. Nothing in your sending code changes.
Push works in two halves: getting a device token from the phone or browser, and telling us about it.
Getting a device token
This part happens inside your app, not against our API. Both Apple and Google hand out a token that identifies one installation on one device, and the usual way to ask for it on all three platforms is Firebase Cloud Messaging:
| Your app | The library |
|---|---|
| Web | Firebase JS SDK — getToken(), plus a service worker |
| iOS | Firebase iOS SDK, or your existing push setup |
| Android | Firebase Android SDK |
| React Native | @react-native-firebase/messaging |
Not sure how to get one? That is normal, and we will do it with you.
Setting up push for the first time means an Apple certificate, a Google project and a service worker, and none of it is obvious. We walk every customer through it during onboarding, including the parts that live in Apple’s and Google’s consoles rather than ours. You do not need to understand Firebase to end up with working push — and the notifications arrive under your own name and icon, not ours.
Ask us to set it upTelling us about it
Once you have a token, register it when somebody signs in and remove it when they sign out.
POST /v1/me/devices
await fetch("https://api.bzzt.now/v1/me/devices", {
method: "POST",
headers: {
Authorization: `Bearer ${bzztToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
token: deviceToken, // the one Firebase gave you
platform: "ios", // "ios" | "android" | "web"
locale: "pl", // optional
}),
});Call it on every app start. Registering the same device twice is harmless, and it is how we know the device is still alive. Someone with a phone, a tablet and two browsers gets the notification on all four. The SDK exports registerDevice and removeDevice if you would rather not write the fetch.
Watch out
Delete the device on sign-out, not just the session.
DELETE /v1/me/devices with the device token and the registration is gone — not disabled, gone.The console
Where the words, the configuration and the evidence live. Your team uses it without filing a ticket with us, and without touching the API.
Send a notification
Send one by hand, without writing any code. Pick a type, fill in the data, choose who gets it — one person, everybody, or everybody matching a label you have set — and send. The same path a real send takes, which makes it the fastest way to test a new template or make an announcement.
Notification types
Create and edit types, the data each carries, which channels are on, and the workflow — “in the feed now, push in ten minutes if unread”. The screen warns you about the combinations that are legal but probably not what you meant.
Templates and preview
One tab per language, with a live preview rendered by the same engine production uses — so what you see is what gets sent. It shows render problems too, like a sentence referring to data the type does not carry.
Translation coverage
A grid of types against languages, showing at a glance what is missing or out of date. This is what makes nineteen languages maintainable rather than a thing nobody wants to touch.
Notifications
Everything sent across all your users, filtered by type, person, status or date, as far back as your plan’s history allows. Open one to see the data it carried and the exact words that person received. Separately, the permanent record of what each person received is kept for a year on every plan.
Deliveries
Every push attempt, per device. It says what a failure means rather than printing a code: a dead address was removed, a quota belongs to your own project, an authentication error is credentials rather than the phone. You can retry one from here.
Users
Your end users, their language, and their devices — including the disabled ones and why. This is the screen that answers “why is this person not getting push?”
Settings and Team
API keys, languages, inboxes, user labels and push credentials. Team invites colleagues by email and gives them a role: owner, editor or viewer.
Everything here is scoped to your workspace. Your colleagues sign in with their work email and see only your data — there is no shared login and no password to pass around.
The five words
Everything in the console is made of these. Nothing else needs explaining.
| Word | What it means |
|---|---|
| type | A kind of notification, like invoice.paid. It owns the data it carries and the templates that write it. |
| template | The words, one row per language. Filled in from the data you send. |
| channel | Where it goes: in_app for the feed, push for every device somebody is signed in on. |
| workflow | The order and timing, per type. “In the feed now, push in ten minutes if they have not read it.” |
| feed | An inbox. You get one by default and most people stay with one; add more if different parts of your product need separate lists. |
When something is wrong
Every error names what to change. These are the ones you are most likely to meet.
| What you get | What it means |
|---|---|
| 400 unknown_type | The type key does not exist in your workspace. Check the spelling against the console. |
| 400 invalid_payload | Your data does not match what the type says it carries. The response names the field. |
| 401 on /v1/me/* | The user token did not verify. Usually an expired token, or an issuer we do not hold for you yet. |
| An empty feed, no error | Almost always the id mismatch in section 03. Nothing is broken; you wrote to one person and read as another. |
| Sent, never arrived | Open Deliveries in the console. It says what happened to each device, in words rather than an error code. |
Stuck on something?
We would rather you asked than spent an afternoon on it. We help every customer through setup, including the parts that involve Apple and Google.
hello@bzzt.now