Bird

Send your first push notification

Register one browser, send it a notification from your terminal and open the destination. This example uses an anonymous test contact and public demo content. It needs Python 3, a browser that supports Web Push, a Bird workspace and an access key authorized to send through your Push channel. Keep that access key on your computer or server.

1. Connect the application

In Bird, open Developer > Applications, create a web application and enable Push notifications. Copy the application's configuration URL and the associated Push channel ID. Web Push uses the channel's VAPID configuration; a Firebase project is unnecessary for this example. See platform setup for Android and iOS.
Create an empty test directory. Save this as service-worker.js:
Code example
importScripts("https://embeddables.p.mbirdcdn.net/sdk/v0/bird-push-sw.js");
Use an isolated test origin. An existing application may already own its service worker: integrate the Bird worker with that implementation and the configured worker path, rather than replacing it. The worker must be on your website's origin and cover the page's path.

2. Register the test browser

Save this as index.html, replacing YOUR_APPLICATION_CONFIG_URL with the URL copied from Bird. The application configuration URL belongs in the browser; the Channels API access key does not.
Code example
<!doctype html>
<html lang="en">
  <meta charset="utf-8" />
  <title>Bird Push test</title>
  <h1>Demo order</h1>
  <p id="order">Your demo order is ready. No customer data is used.</p>
  <button id="subscribe" disabled>Enable a test notification</button>
  <p id="status" role="status">Loading Bird…</p>
  <label for="token">Test token — keep private</label
  ><br />
  <textarea id="token" readonly rows="4" cols="60"></textarea>
  <script
    src="https://embeddables.p.mbirdcdn.net/sdk/v0/bird-sdk.js"
    data-config-url="YOUR_APPLICATION_CONFIG_URL"
  ></script>
  <script>
    const button = document.getElementById("subscribe");
    const status = document.getElementById("status");
    const token = document.getElementById("token");
    Bird.eventTarget.addEventListener("bird-sdk-initialized", () => {
      button.disabled = false;
      status.textContent = "Ready. Choose Enable to request permission.";
    });
    button.addEventListener("click", async () => {
      button.disabled = true;
      try {
        token.value = await Bird.pushNotifications.subscribe();
        status.textContent = "Registered. Copy the token into your local send command.";
      } catch (error) {
        status.textContent = `Registration failed: ${error.message}`;
        button.disabled = false;
      }
    });
    if (new URLSearchParams(location.search).get("opened") === "1") {
      document.getElementById("order").textContent =
        "You opened the demo order from its notification link.";
    }
  </script>
</html>
Run python3 -m http.server 8080 in that directory and open http://localhost:8080. Localhost is suitable for this desktop browser test. Use an HTTPS test deployment for a remote device. Select Enable a test notification, allow the browser prompt and copy the returned token. Do not repeatedly request permission after a denial; use permission troubleshooting.
Bird.pushNotifications.subscribe() registers the subscription with the current Bird contact and returns its opaque token. Do not decode or alter it. This test does not set marketing consent. For a signed-in customer, complete identity and consent setup before sending private or campaign content. The Web Push SDK guide describes the integration.

3. Send one notification

Choose one send example below: Node.js 22 or later, Python 3, or cURL with jq. Save it outside the directory served by the test web server. Each uses the Channels API, with AccessKey authentication and body.type: "list". This API uses https://api.bird.com/workspaces/.../channels/...; use its access key, workspace ID and channel ID together.
For Python, save send_push.py and run python3 send_push.py after setting the environment variables below:
Code example
import json
import os
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from uuid import UUID

workspace = str(UUID(os.environ["BIRD_WORKSPACE_ID"]))
channel = str(UUID(os.environ["BIRD_CHANNEL_ID"]))
access_key = os.environ["BIRD_ACCESS_KEY"]
token = os.environ["BIRD_PUSH_TOKEN"]  # subscribe() result, without "web:"
destination = os.environ.get("BIRD_TEST_URL", "http://localhost:8080/?opened=1")
if not access_key or not token or token.startswith("web:"):
    raise SystemExit("Set the access key and the unprefixed SDK token.")
if urlparse(destination).scheme not in ("https", "http"):
    raise SystemExit("Use an HTTP(S) test destination.")

base = f"https://api.bird.com/workspaces/{workspace}/channels/{channel}/messages"
headers = {"Authorization": f"AccessKey {access_key}", "Content-Type": "application/json"}
body = {
    "receiver": {"contacts": [{
        "identifierKey": f"push-{channel}",
        "identifierValue": f"web:{token}",
    }]},
    "body": {"type": "list", "list": {
        "title": "Your demo order is ready",
        "text": "Open the order to see the collection details.",
        "actions": [{"type": "link", "link": {
            "text": "View order", "url": destination,
        }}],
    }},
}

# The exclusive local record prevents accidentally resending this test.
# It does not provide server-side idempotency.
receipt = Path("push-result.json")
with receipt.open("x", encoding="utf-8") as output:
    json.dump({"state": "attempt_started"}, output)
try:
    request = Request(base, data=json.dumps(body).encode(), headers=headers, method="POST")
    with urlopen(request, timeout=30) as response:
        result = json.load(response)
        if response.status != 202 or not isinstance(result.get("id"), str) or not result["id"]:
            raise ValueError("No accepted message ID in the response.")
        record = {"http_status": response.status, "id": result["id"],
                  "status": result.get("status")}
    receipt.write_text(json.dumps(record, indent=2) + "\n")
    print(json.dumps(record, indent=2))
except HTTPError as error:
    receipt.write_text(json.dumps({"state": "http_error", "http_status": error.code}) + "\n")
    raise SystemExit(f"HTTP {error.code}; investigate before sending again.")
except (URLError, TimeoutError, OSError, ValueError) as error:
    raise SystemExit("Outcome unknown. Keep push-result.json and reconcile before resending.") from error
For Node.js, save send-push.mjs and run node send-push.mjs. This uses the built-in HTTP client:
Code example
import { writeFile } from "node:fs/promises";

const workspace = process.env.BIRD_WORKSPACE_ID;
const channel = process.env.BIRD_CHANNEL_ID;
const accessKey = process.env.BIRD_ACCESS_KEY;
const token = process.env.BIRD_PUSH_TOKEN;
const destination = new URL(process.env.BIRD_TEST_URL ?? "http://localhost:8080/?opened=1");
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (
  !uuid.test(workspace ?? "") ||
  !uuid.test(channel ?? "") ||
  !accessKey ||
  !token ||
  token.startsWith("web:")
) {
  throw new Error("Set workspace/channel UUIDs, access key, and the unprefixed SDK token.");
}
if (!["http:", "https:"].includes(destination.protocol))
  throw new Error("Use an HTTP(S) test destination.");
const body = {
  receiver: { contacts: [{ identifierKey: `push-${channel}`, identifierValue: `web:${token}` }] },
  body: {
    type: "list",
    list: {
      title: "Your demo order is ready",
      text: "Open the order to see the collection details.",
      actions: [{ type: "link", link: { text: "View order", url: destination.href } }],
    },
  },
};
const receipt = "push-result.json";
// Refuse a second local attempt, including after a lost response.
await writeFile(receipt, JSON.stringify({ state: "attempt_started" }) + "\n", { flag: "wx" });
try {
  const response = await fetch(
    `https://api.bird.com/workspaces/${workspace}/channels/${channel}/messages`,
    {
      method: "POST",
      headers: { Authorization: `AccessKey ${accessKey}`, "Content-Type": "application/json" },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(30_000),
    },
  );
  if (!response.ok) {
    await writeFile(
      receipt,
      JSON.stringify({ state: "http_error", http_status: response.status }) + "\n",
    );
    throw new Error(`HTTP ${response.status}; investigate before sending again.`);
  }
  const result = await response.json();
  if (response.status !== 202 || typeof result.id !== "string" || !result.id)
    throw new Error("No accepted message ID.");
  const record = { http_status: response.status, id: result.id, status: result.status };
  await writeFile(receipt, JSON.stringify(record, null, 2) + "\n");
  console.log(record);
} catch (error) {
  console.error(error.message);
  console.error("Keep push-result.json and reconcile this attempt before resending.");
  process.exitCode = 1;
}
For cURL, save send-push.sh and run sh send-push.sh. jq escapes the token and destination when constructing the JSON:
Code example
set -eu
: "${BIRD_WORKSPACE_ID:?Set the workspace UUID}"
: "${BIRD_CHANNEL_ID:?Set the Push channel UUID}"
: "${BIRD_ACCESS_KEY:?Set the Channels API access key}"
: "${BIRD_PUSH_TOKEN:?Set the unprefixed SDK token}"
case "$BIRD_PUSH_TOKEN" in web:*) echo 'Use the token without web:' >&2; exit 1;; esac
BIRD_TEST_URL=${BIRD_TEST_URL:-http://localhost:8080/?opened=1}
case "$BIRD_TEST_URL" in https://*|http://*) ;; *) echo 'Use an HTTP(S) test destination' >&2; exit 1;; esac
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
jq -n --arg channel "$BIRD_CHANNEL_ID" --arg token "$BIRD_PUSH_TOKEN" --arg url "$BIRD_TEST_URL" '
  {receiver: {contacts: [{identifierKey: ("push-" + $channel), identifierValue: ("web:" + $token)}]},
   body: {type: "list", list: {
     title: "Your demo order is ready", text: "Open the order to see the collection details.",
     actions: [{type: "link", link: {text: "View order", url: $url}}]
   }}}
' > "$work/request.json"
# noclobber refuses a second attempt, including after a lost response.
(set -C; printf '%s\n' '{"state":"attempt_started"}' > push-result.json)
if code=$(curl --silent --show-error --max-time 30 --request POST \
  "https://api.bird.com/workspaces/$BIRD_WORKSPACE_ID/channels/$BIRD_CHANNEL_ID/messages" \
  --header "Authorization: AccessKey $BIRD_ACCESS_KEY" \
  --header 'Content-Type: application/json' --data-binary @"$work/request.json" \
  --output "$work/response.json" --write-out '%{http_code}'); then
  if [ "$code" = 202 ] && jq -e '.id | type == "string" and length > 0' "$work/response.json" >/dev/null; then
    jq '{http_status: 202, id, status}' "$work/response.json" > push-result.json
    cat push-result.json
  else
    echo "HTTP $code without an accepted receipt; keep push-result.json and investigate." >&2
    exit 1
  fi
else
  echo 'Outcome unknown. Keep push-result.json and reconcile before resending.' >&2
  exit 1
fi
In a second terminal, set these environment variables privately. Run only your chosen script from its directory; all three examples use push-result.json to record the same test attempt:
Code example
export BIRD_WORKSPACE_ID='YOUR_WORKSPACE_UUID'
export BIRD_CHANNEL_ID='YOUR_PUSH_CHANNEL_UUID'
export BIRD_ACCESS_KEY='YOUR_ACCESS_KEY'
export BIRD_PUSH_TOKEN='TOKEN_COPIED_FROM_THE_TEST_PAGE'
For an HTTPS test deployment, also set BIRD_TEST_URL to its /?opened=1 URL. Keep credentials and tokens out of source control and shared terminal recordings. The local receipt contains the message ID and status, not the token or key. A 202 response means the send was accepted for processing. The file prevents repeating this local test by accident; do not remove it to retry an uncertain send.

4. Open the notification and inspect the result

Open the notification and confirm that the demo page says you opened the order from its link. Repeat as separate, deliberately recorded tests with the browser page visible, in the background and closed, where the browser supports background delivery. Browser and OS settings still control presentation.
Fetch the original message using its returned ID to inspect processing status and any failure:
Code example
export BIRD_MESSAGE_ID='MESSAGE_ID_FROM_PUSH_RESULT'
curl --fail-with-body \
  "https://api.bird.com/workspaces/$BIRD_WORKSPACE_ID/channels/$BIRD_CHANNEL_ID/messages/$BIRD_MESSAGE_ID" \
  -H "Authorization: AccessKey $BIRD_ACCESS_KEY"
Check status and, when present, failure.code, failure.description and failure.source. Acceptance, notification presentation, a tap and a completed customer action are separate observations. If there is no message ID because the response was lost, reconcile the attempt in Bird before issuing another send. See troubleshooting.

Next steps

Related resources

Continue with the documentation, guides and examples for this topic. Resources are in English.

Get an implementation brief