Nineteen languages, one row each. See how copy works in Bzzt

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.

  1. 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.

  2. 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.

  3. 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. 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. 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. 3

    Send one

    From your backend, with the API key from Settings. recipients holds 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 Accepted straight 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. 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 content and you are done; use data if 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, fetchNextPage and 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.

The send succeeds. The read succeeds. There is simply nothing there, because you wrote to one person and read as somebody who does not exist. It catches teams who send their own database id but mint tokens from their login provider, which uses a different id for the same human — 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.

FieldWhat it does
typeThe notification type key you created in the console. Required.
recipientsYour user ids. A plain list of strings, or objects if you want to set a language or an inbox per person. Required.
payloadThe data behind the notification, checked against the type. Your templates fill themselves in from this.
dedupeKeyYour own idempotency key. Send the same one twice and the second is ignored, so a retry after a timeout cannot notify somebody twice.
contentPre-written text that skips the template, per channel. Useful while you are still translating, or for one-off messages not worth a template.
feedKeyWhich 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.

EndpointWhat it gives you
GET /v1/me/notificationsThe feed, newest first. Takes limit, cursor to page back through history, and unread=true to filter.
GET /v1/me/notifications/countsThe unread and unseen counts. This is your bell badge.
POST /v1/me/notifications/seenMark several as seen at once, when they scroll into view.
POST /v1/me/notifications/:id/readMark one as read, when it is opened.
POST /v1/me/notifications/read-allThe “mark all as read” button.
POST /v1/me/notifications/:id/archiveDismiss 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.

Re-fetch the feed when you connect, when you reconnect, and when the tab regains focus. Do that and a dropped socket is invisible to the user. We close idle connections after 45 minutes with code 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 appThe library
WebFirebase JS SDK — getToken(), plus a service worker
iOSFirebase iOS SDK, or your existing push setup
AndroidFirebase 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 up

Telling 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.

A shared device that keeps a registration will keep buzzing for somebody who has signed out. Send 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.

WordWhat it means
typeA kind of notification, like invoice.paid. It owns the data it carries and the templates that write it.
templateThe words, one row per language. Filled in from the data you send.
channelWhere it goes: in_app for the feed, push for every device somebody is signed in on.
workflowThe order and timing, per type. “In the feed now, push in ten minutes if they have not read it.”
feedAn 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 getWhat it means
400 unknown_typeThe type key does not exist in your workspace. Check the spelling against the console.
400 invalid_payloadYour 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 errorAlmost always the id mismatch in section 03. Nothing is broken; you wrote to one person and read as another.
Sent, never arrivedOpen 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