Platform-wide by default. Filter to one application or channel to narrow the view.
Loading metrics…
Example
What Observability looks like for an application you own, shown with sample data.
Sample dataThese numbers are made up. Create an application and send a notification to see your own.
Documentation
Everything a tenant application needs to integrate with the Notification Service.
01 · Getting Started
What the service provides
The Notification Service is a shared platform for sending Slack, Email, and Push notifications
from your application's backend. You call it once through the Backend Notification SDK; the
service handles queueing, retries with exponential backoff, and provider-specific delivery
(Slack Web API, AWS SES, Firebase Cloud Messaging).
In this system, one application is one tenant. Your application name doubles as
your tenant_id and is immutable once created.
Set up with a coding agent
Download AGENTS.md and give it to Claude Code or Codex in your project. It contains
instructions to help the agent set up the Notification Service integration in your codebase.
When an authorized member or admin creates your application in this portal, it generates an API key shown exactly
once. Store it securely (e.g. a secrets manager) — it authenticates every call your backend makes
to the Notification API via the X-Tenant-ID / X-API-Key headers. It is
never sent to, or usable from, a browser.
Basic architecture
Your Backend ──▶ (Backend Notification SDK) ──▶ Notification API ──▶ Redis Stream ──▶ Notification Worker ──▶ Slack / SES / FCM
POST /v1/notifications/* returns 202 Accepted as soon as the
notification is durably queued — it does not wait for the message to actually reach Slack, SES,
or the push provider.
Installed from this platform's internal Python package index, pypi.qure.ai — it
is not anonymously readable, so UV_INDEX_QURE_USERNAME /
UV_INDEX_QURE_PASSWORD must be set before uv add/uv sync
will resolve it. Ask the platform team for a read-only account if you don't have one. The
distribution is named notify-sdk (not notification-sdk — that name is
already taken by an unrelated package on public PyPI); the importable module is still
notification_sdk, as in every example below.
# One-time setup: this is a private module, so keep it off the public
# proxy and let git authenticate to GitHub.
go env -w GOPRIVATE='github.com/qureai/*'
git config --global url."git@github.com:qureai/".insteadOf "https://github.com/qureai/"
go get github.com/qureai/agentic-ai-team-notification-lib/sdk-go@v0.1.0
The Go SDK lives in this same repository, under sdk-go/, as a nested Go module.
That repository is private, so go get needs
GOPRIVATE set and an authenticated git (SSH as above, or a
~/.netrc entry with a Personal Access Token) — without them it fails with a
confusing 410 Gone from the public module proxy rather than an auth error.
Requires Go 1.21+. The module's version tags are prefixed
(sdk-go/v0.1.0) but you still ask for @v0.1.0. See
sdk-go/README.md in the repo for the CI/Docker variants. Import the
notification package:
import "github.com/qureai/agentic-ai-team-notification-lib/sdk-go/notification",
as in every example below.
That first go get is slow and silent — Go clones the whole repo into its VCS
cache and probes tags one at a time over SSH, so allow it about a minute and don't wrap it in
a short timeout. If you interrupt it, the retry can hang forever on
# lock .../cache/vcs/<hash>.lock: an orphaned child still holds the lock, so
pgrep -fl 'go (get|list)', kill it, and run again. go get -x shows
the git commands as they happen.
Configuration
from notification_sdk import NotificationClient
client = NotificationClient(
tenant_id="your-application-name",
api_key="...", # from this portal, stored securely
base_url="https://notifications.ryu.qure.ai", # the Notification Service's own API address
)
import "github.com/qureai/agentic-ai-team-notification-lib/sdk-go/notification"
client, err := notification.NewClient(
"your-application-name",
"...", // from this portal, stored securely
notification.WithBaseURL("https://notifications.ryu.qure.ai"), // the Notification Service's own API address
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
base_url is not specific to your application - it's simply where the Notification
Service's API itself is reachable, the same for every application in a given environment.
It's configurable rather than hardcoded because that address differs by environment: it
defaults to http://localhost:8000 for local development, and points at your
actual deployment's internal hostname everywhere else. tenant_id/api_key
identify who's calling; base_url only ever says where to call.
Authentication / credential usage
Every request is authenticated with the X-Tenant-ID and X-API-Key
headers — the SDK sets these for you from the constructor arguments above. There is no
separate registration credential for Push: it reuses this same tenant credential.
Idempotency
Pass an idempotency_key on every send_* call. If omitted, the SDK
generates one that only protects that call's own automatic retries, not your later retries. It
exposes the key on failed sends so you can reuse it for a later retry. Explicit blank keys are
rejected in Python; Go treats an empty string as omitted and rejects whitespace-only keys. It
should represent one logical notification submission — the same logical notification always
reuses the same key; a new logical notification uses a new key. A retried call with the same key
and payload is recognized as a safe replay and returns the original notification rather than
creating a duplicate. Reusing a key with a different payload is rejected with
409 Conflict.
# Derive the key from the business event itself, and reuse that exact
# value for every attempt at sending it - never generate a fresh one
# per call/retry, or the API can no longer tell a retry apart from a
# brand-new notification.
idempotency_key = f"order-{order.id}-shipped"
client.send_email(..., idempotency_key=idempotency_key)
// Derive the key from the business event itself, and reuse that exact
// value for every attempt at sending it - never generate a fresh one
// per call/retry, or the API can no longer tell a retry apart from a
// brand-new notification.
idempotencyKey := fmt.Sprintf("order-%s-shipped", order.ID)
client.SendEmail(ctx, notification.SendEmailRequest{
To: to, Subject: subject, Body: body, IdempotencyKey: idempotencyKey, CC: cc,
})
A random UUID works fine as the key's shape, but only if you generate it once when the
business event happens and persist/reuse it - e.g. store it alongside the order/job record, not
mint a new one inside your send call each time.
Error handling
The SDK performs a small number of automatic, short-lived retries for transient failures
(connection timeouts, network errors, HTTP 502/503/504, and 429 respecting
Retry-After), always reusing the same idempotency key. If the retry budget is
exhausted, it raises NotificationSubmissionError in Python
(returns *notification.NotificationSubmissionError in Go). Non-retryable failures
(400/401/403/409) raise immediately in Python, and are returned immediately as an
error in Go:
ValidationError (Go: *notification.ValidationError) — the request was malformed before it was even sent
AuthenticationError (Go: *notification.AuthenticationError) — invalid or deactivated tenant credentials (401/403)
ConflictError (Go: *notification.ConflictError) — the idempotency key was reused with a different payload (409)
NotificationSubmissionError (Go: *notification.NotificationSubmissionError) — transient failures, including 429, exhausted the retry budget; reuse its idempotency key if retrying later
APIError (Go: *notification.APIError) — a non-retryable API response outside the cases above
In Go, every one of these wraps a package-level sentinel error (notification.ErrValidation,
etc.) so you can branch with errors.Is/errors.As instead of a Python
except clause.
03 · Slack
Required setup
Ask an operator to attach a Slack bot token to your application in this portal (Create
Application, or Edit Channels on an existing one). The bot must be invited to any channel you
plan to post into. If you leave this blank, your application falls back to the shared qHive
default bot token.
Sending notifications
response = client.send_slack(
recipients=["#engineering"],
body="Deployment to production has failed.",
idempotency_key=deploy_event_id,
)
print(response.notification_id, response.status)
resp, err := client.SendSlack(ctx, notification.SendSlackRequest{
Recipients: []string{"#engineering"},
Body: "Deployment to production has failed.",
IdempotencyKey: deployEventID,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.NotificationID, resp.Status)
recipients is a list of one or more Slack channel names, Slack user IDs, or email
addresses (resolved to a Slack user via users.lookupByEmail) — the notification is
fanned out to every recipient in the list, and each recipient's delivery outcome is independent.
Attachments
from notification_sdk import Attachment
response = client.send_slack(
recipients=["#reports"],
body="This week's export is attached.",
attachments=[Attachment.from_file("export.csv")],
idempotency_key=export_job_id,
)
Requires the bot token to have the files:write scope in addition to whatever it
already has — see SLACK_ATTACHMENT_SCOPE_MISSING below if that's missing.
Sending in one message also uses channels:read/groups:read (to find
channels) and im:write (DMs). The files are sent in the same message as the text and
button; if the bot can't (e.g. it can't open the DM), the message is posted first and the files go in a thread reply. Each recipient gets its own upload,
and each recipient's outcome is independent. File type is a fixed allowlist — common documents including .md, images, .json/.log, and .zip.
Defaults (configurable server-side) — a request over any of them is rejected with 422:
Up to 5 files, each up to 5 MiB, filename up to 255 characters.
Up to 15 MiB in total across the notification's files (Email's total is lower — see Email).
If the files can't be uploaded
The recipient still has the message. After a few automatic retries the worker posts a
":warning: attachment(s) could not be uploaded" reply in the thread, and the delivery is
recorded as Partial (SLACK_ATTACHMENT_UPLOAD_FAILED, or
SLACK_ATTACHMENT_SCOPE_MISSING if the bot lacks files:write) — not
Failed. It is not retried automatically, since a retry would repost the
message; re-send only if the files matter.
Example response
This is a real response captured from a live send, not a placeholder:
202 Accepted means the notification is durably queued — not yet posted to Slack.
The worker delivers it moments later. Once delivered, the delivery record identifies the Slack
message as <channel_id>:<ts> — for example
C0BA7STT3MH:1790669803.925599, where the first part is the channel or DM ID.
04 · Email
Required setup
None — Email works out of the box. Because of qure.ai's DMARC policy, every sender address is
of the form notifier-<name>@qure.ai, and you never choose or override the
From address at send time; if an operator hasn't attached a sender email to your application in
this portal, it defaults to notifier-<application_name>@qure.ai.
Recipients are qure.ai-only for now
Only @qure.ai recipients (to, cc, and bcc)
are supported today — sending to any other domain will fail.
Sending notifications
from notification_sdk import Attachment
response = client.send_email(
to=["user@qure.ai"],
cc=[],
bcc=["audit@qure.ai"],
subject="Your report is ready",
body="The weekly report has been generated and is attached.",
attachments=[Attachment.from_file("report.pdf")],
idempotency_key=report_job_id,
)
print(response.notification_id, response.status)
resp, err := client.SendEmail(ctx, notification.SendEmailRequest{
To: []string{"user@qure.ai"},
BCC: []string{"audit@qure.ai"},
Subject: "Your report is ready",
Body: "The weekly report has been generated and is attached.",
Attachments: []notification.Attachment{
{Filename: "report.pdf", ContentType: "application/pdf", Content: reportBytes},
},
IdempotencyKey: reportJobID,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.NotificationID, resp.Status)
bcc recipients never appear in the message any other recipient sees, and count
toward the same recipient cap as to/cc. Attachments are checked against a
file-type allowlist (PDF, Office documents, csv/txt/md/rtf/log/json, common images, and zip) and
size limits — by default up to 5 files, each up to 5 MiB, 6 MiB in total, and the assembled
message must stay under SES's 10 MB limit — and can be combined freely with button.
HTML/SVG, executables and scripts are rejected, including inside a zip.
Example response
This is a real response captured from a live send, not a placeholder:
As with every channel, 202 Accepted only means the notification was durably queued.
That particular send was confirmed accepted by SES moments later with its own provider message ID
(010001a0280d279f-...-000000) — SES accepting the message is not the
same as the recipient's mailbox receiving it (see "Delivery Failures" below for what this platform
does and doesn't observe about actual mailbox delivery).
Unsubscribe / resubscribe
A per-address opt-out for the Email channel. There is no public/unauthenticated unsubscribe link —
your own backend calls this using your existing tenant credential, after your product's own
settings UI lets the user flip the toggle.
client.set_email_subscription(email="user@qure.ai", unsubscribed=True)
# ... and to resubscribe later:
client.set_email_subscription(email="user@qure.ai", unsubscribed=False)
Example response
{
"email": "user@qure.ai",
"unsubscribed": true
}
Enforced only against to, never cc — the toggle means "stop making me a
primary recipient", not "scrub my address out of every email that happens to mention me". An
unsubscribed to address is dropped before the send reaches SES; if every
to address on a request is unsubscribed, that delivery records
EMAIL_ALL_RECIPIENTS_UNSUBSCRIBED instead of calling SES at all.
Go SDK
Not available yet for Email subscription — Python only for now.
05 · Push Notifications
End-to-end flow
Push is the most involved channel because a browser can never talk to the Notification Service
directly, and your tenant credential never reaches the frontend:
Tenant Frontend
│ (Frontend Push SDK)
▼
Firebase Web SDK obtains an FCM token
│
▼
Tenant Frontend ──(token)──▶ Tenant Backend
│ (Backend Notification SDK,
│ your existing tenant credential)
▼
Notification Service
│
▼
push_devices
Tenant credentials never go to the frontend.
The frontend never registers with the Notification Service directly.
Registration always happens through your backend, using your existing long-lived tenant credential.
Token updates and unregister/logout follow the exact same backend path.
Frontend Push SDK installation
There is no npm registry for this platform yet, so
@notification-platform/push-sdk-web isn't npm install-able. Instead,
copy the src/ directory from
frontend/push-sdk-web
straight into your own frontend repo and own the code from there; re-copy it to pick up updates.
Import from the path where you copied src/index.js; the package name above is not
an installed module.
It also needs firebase itself in your own project — it's a
peerDependency (^10.0.0), not bundled into src/:
npm install firebase
src/index.js imports directly from firebase/app and
firebase/messaging, so without this the copied code won't resolve.
Also serve the platform's firebase-messaging-sw.js unmodified at your site's origin
root (i.e. reachable at https://your-app.example.com/firebase-messaging-sw.js) — it
is already configured for this platform's shared Firebase project, with nothing for you to fill
in.
import { NotificationPushSDK } from "./notification-push/src/index.js"; // adjust to your copied path
let pendingRegistration = null;
const push = new NotificationPushSDK({
onToken: (token) => { pendingRegistration = sendTokenToMyBackend(token); },
});
await push.init(); // idempotent — safe to call on every page load / login
if (pendingRegistration) await pendingRegistration; // the SDK does not await onToken
FCM token acquisition & sending it to your backend
onToken fires whenever a token is first obtained or changes. Your frontend's only
job is forwarding that token to your own backend endpoint — never to the Notification
Service directly.
Backend device registration
response = client.register_push_device(
user_id=current_user.id, # from your own authenticated session
token=token_from_frontend,
platform="web",
)
print(response.push_device_id, response.status)
reg, err := client.RegisterPushDevice(ctx, notification.RegisterPushDeviceRequest{
UserID: currentUser.ID, // from your own authenticated session
Token: tokenFromFrontend,
Platform: "web",
// Provider left blank defaults to "fcm".
})
if err != nil {
log.Fatal(err)
}
fmt.Println(reg.PushDeviceID, reg.Status)
Example response
This is a real response captured from a live registration, not a placeholder:
push_device_id identifies this one registration row — not the physical device or the
token itself. Registering the same (tenant, provider, token) again returns this same
push_device_id with status reset to ACTIVE, rather than
creating a duplicate.
Token updates
Call the same registration method again with the new token
(register_push_device in Python, RegisterPushDevice in Go) — it
upserts by (tenant, provider, token), so this is safe to call as often as needed.
Sending notifications
Once at least one device is registered for a user, send to them by user_ids - the
same call fans out to every device each of those users currently has registered:
data is an arbitrary key/value payload your own app defines
(dict[str, string] in Python, map[string]string in Go), with three
reserved keys you can't use: title and body are merged into this same
data map before the message reaches the device (push messages are sent data-only,
deliberately, so the browser never auto-displays a notification outside of
firebase-messaging-sw.js's own handler), and notification_id carries
this notification's ID for that handler's dedup tag (collapses two
independently-delivered pushes for the same notification into one shown banner instead of
stacking both). Supplying any of the three yourself in data gets rejected with a
422. Your frontend reads your own custom keys back from
onForegroundMessage(payload) as payload.data.deploy_id (or from a
custom notificationclick handler as event.notification.data.deploy_id)
to do whatever your app needs - e.g. deciding what to refresh once the user is back on the page.
One key is special: firebase-messaging-sw.js's stock background handler already
looks for click_action and opens that URL when the user clicks the OS notification
banner - e.g. data={"click_action": "/deployments/123"}.
Example response
This is a real response captured from a live send, not a placeholder:
Like Slack/Email, this returns as soon as the notification is queued - it does not wait for
delivery to any device. Each device's outcome is independent: one browser having an invalid
token never blocks delivery to the user's other devices, and isn't reflected in this response
- see Delivery Failures for where per-device Push outcomes actually
show up. That particular send was confirmed accepted by FCM with its own per-device provider
message ID (projects/<firebase-project>/messages/a1f66c66-...).
What the end user actually sees
If their browser tab is closed or in the background, this shows up as a real OS notification
banner via the stock firebase-messaging-sw.js's onBackgroundMessage
handler this platform ships - it reads title/body back out of
payload.data and calls showNotification itself, so you get this for
free as long as you copied that file in unmodified (see "Frontend setup" above). The browser
does not display anything automatically on its own - push messages are sent
data-only specifically so nothing shows up twice (see the data note above). If
their tab is focused/foreground, the message instead goes to your own
onForegroundMessage callback, and it's up to your frontend to decide how to
surface it (an in-app toast, a badge count, etc.).
Unregister on logout
status = client.unregister_push_device(user_id=current_user.id, token=current_token)
print(status)
This is a real response captured from a live unregister call — note it's the bare status string,
not an object like the two responses above:
"INACTIVE"
06 · In-App Notifications
Overview
Build your own bell/feed UI against four read/write endpoints, proxied through your backend. Same
trust model as Push device registration: user_id comes from your backend's own
authenticated session, never trusted from the browser.
Your Backend ──▶ (Backend Notification SDK) ──▶ Notification API ──▶ deliveries table
Like every other channel, 202 Accepted only means the notification was queued, not
yet delivered. Each user_id's delivery is independent, the same fan-out as Slack.
Reading the feed
Your frontend never calls the Notification Service directly — it calls your own backend, which
proxies to these four:
User opens the notification center, or scrolls for more — call
list_in_app_notifications (paginate with the returned next_cursor).
Every few seconds while your frontend is open — poll
get_in_app_unread_count for the bell badge.
User clicks a notification — call mark_in_app_notification_read, then navigate
them using that notification's own url, if it has one.
User clicks "mark all as read" on the notification center — call
mark_all_in_app_notifications_read to flip every one of their unread notifications
to read in a single call.
page = client.list_in_app_notifications(user_id="user_1")
for item in page.items:
print(item.notification_id, item.title, item.body, item.read_at)
# page.next_cursor is None once there are no more pages
unread = client.get_in_app_unread_count(user_id="user_1")
print(unread.unread_count) # for the bell badge
client.mark_in_app_notification_read(user_id="user_1", notification_id=page.items[0].notification_id)
result = client.mark_all_in_app_notifications_read(user_id="user_1")
print(result.marked_read) # how many rows were flipped to READ
Marking an already-read (or unknown) notification read is a safe no-op — safe to call again.
Same for mark-all-read: calling it again once nothing is left unread just returns
marked_read=0.
Go SDK
Not available yet for in-app — Python only for now.
07 · Delivery Failures
Error codes and meanings
Failed deliveries in Applications → Logs carry a stable
error_code alongside a sanitized, human-readable reason. The log includes
Slack, Email, and Push, with outcomes grouped under each notification.
Error Code
Meaning
SLACK_CHANNEL_NOT_FOUND
The destination channel doesn't exist.
SLACK_CHANNEL_ACCESS_DENIED
The bot isn't a member of the channel, or the channel is archived/restricted.
SLACK_INVALID_CREDENTIALS
The configured Slack bot token is invalid, revoked, or missing scopes.
SLACK_CREDENTIAL_MISSING
No Slack bot token is configured for this application or the shared qHive default.
SLACK_RECIPIENT_NOT_FOUND
No Slack user matched the given email address.
SLACK_MESSAGE_REJECTED
Slack rejected the message itself (too long, empty text).
SLACK_RATE_LIMITED
Slack's API rate limit was hit (retried automatically).
SLACK_ATTACHMENT_SCOPE_MISSING
The bot token is otherwise valid but lacks the files:write scope needed for attachments — plain messages keep working; add the scope and reinstall the Slack app.
SLACK_ATTACHMENT_UPLOAD_FAILED
The message itself was delivered to Slack, but its attachments could not be uploaded to the thread after several attempts — the recipient has the message without the files. Recorded as a partial success (shown as Partial in the notification log), not a failure. Not retried automatically (a retry would repost the message).
EMAIL_INVALID_RECIPIENT
The recipient address was rejected by the provider.
EMAIL_SENDER_NOT_ALLOWED
The sender identity isn't verified with the email provider.
EMAIL_PROVIDER_REJECTED
The email provider rejected the request for another reason.
EMAIL_ALL_RECIPIENTS_UNSUBSCRIBED
Every to recipient has unsubscribed from Email — expected, not a defect; SES is never called.
FCM_INVALID_TOKEN / FCM_TOKEN_UNREGISTERED
That one device's push token is no longer valid — other devices are unaffected.
A transient provider issue — the worker retries automatically before giving up.
Partial deliveries
A notification shows as Partial in the log when it reached some, but not all,
of what was asked. Two cases: one Slack notification sent to several recipients where some
failed, or a Slack message that was delivered but whose attachments couldn't be uploaded
(SLACK_ATTACHMENT_UPLOAD_FAILED). In the second case the recipient has the message
without its files, and the delivery isn't retried automatically — a retry would repost the
message.
How Push failures appear in the log
A single push notification can fan out to several of a user's devices:
user_123
├── device A → success
├── device B → invalid token
└── device C → success
The notification log groups these device outcomes under one Push notification. It shows a
partial result when some devices succeeded and others failed, with each device's delivery
status available in the entry.
08 · Examples
Backend
from notification_sdk import NotificationClient
client = NotificationClient(tenant_id="growth-team", api_key=API_KEY)
client.send_slack(
recipients=["#alerts"],
body="Nightly job finished with 3 warnings.",
idempotency_key=f"nightly-job-{run_id}",
)
client.send_email(
to=["ops@qure.ai"],
subject="Weekly digest",
body="Attached is this week's usage summary.",
idempotency_key=f"weekly-digest-{week_id}",
)
client.send_push(
user_ids=["user_1"],
title="Deployment completed",
body="Production deploy finished successfully.",
idempotency_key=f"deploy-{deploy_id}",
)
client.send_in_app(
user_ids=["user_1"],
title="Ticket updated",
body="Your ticket #42 moved to Done.",
idempotency_key=f"ticket-{ticket_id}-done",
)
The handlers your own backend exposes at /api/push/register and
/api/push/unregister (called from the frontend example below) just forward into
the same SDK client - user_id always comes from your own authenticated session,
never from the request body. Note the two calls don't return the same shape:
register_push_device returns an object (.push_device_id /
.status), while unregister_push_device returns the status string
directly - there's no .status to access on it.
def handle_push_register(request):
user_id = current_user_id_from_session(request) # never trust a user_id the browser sends directly
response = client.register_push_device(
user_id=user_id,
token=request.json["token"],
platform=request.json["platform"],
)
print(response.status) # "ACTIVE"
return {"push_device_id": response.push_device_id, "status": response.status}
def handle_push_unregister(request):
user_id = current_user_id_from_session(request)
status = client.unregister_push_device(user_id=user_id, token=request.json["token"])
print(status) # "INACTIVE" - already the plain status string, not an object
return {"status": status}
The handlers your own backend exposes at /api/push/register and
/api/push/unregister (called from the frontend example below) just forward into
the same SDK client - userID always comes from your own authenticated session,
never from the request body. Note the two calls don't return the same shape:
RegisterPushDevice returns (*PushDeviceRegistration, error)
(.PushDeviceID / .Status), while UnregisterPushDevice
returns (string, error) directly - there's no .Status field to
access on it.
func handlePushRegister(w http.ResponseWriter, r *http.Request) {
userID := currentUserIDFromSession(r) // never trust a user_id the browser sends directly
var body struct{ Token, Platform string }
json.NewDecoder(r.Body).Decode(&body)
reg, err := client.RegisterPushDevice(r.Context(), notification.RegisterPushDeviceRequest{
UserID: userID,
Token: body.Token,
Platform: body.Platform,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// reg.Status == "ACTIVE"
json.NewEncoder(w).Encode(map[string]string{
"push_device_id": reg.PushDeviceID,
"status": reg.Status,
})
}
func handlePushUnregister(w http.ResponseWriter, r *http.Request) {
userID := currentUserIDFromSession(r)
var body struct{ Token string }
json.NewDecoder(r.Body).Decode(&body)
status, err := client.UnregisterPushDevice(r.Context(), notification.UnregisterPushDeviceRequest{
UserID: userID,
Token: body.Token,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// status == "INACTIVE" - already the plain status string, not an object
json.NewEncoder(w).Encode(map[string]string{"status": status})
}
Frontend (JavaScript/TypeScript)
import { NotificationPushSDK } from "./notification-push/src/index.js"; // adjust to your copied path
let pendingRegistration = null;
const push = new NotificationPushSDK({
onToken: (token) => {
pendingRegistration = fetch("/api/push/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, platform: push.platform }),
}).then((response) => {
if (!response.ok) throw new Error(`Push registration failed: ${response.status}`);
});
},
});
async function initializePush() {
await push.init();
if (pendingRegistration) await pendingRegistration;
}
// Call await initializePush() in your login/opt-in flow too. Reconcile on
// every page load if permission was already granted.
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
void initializePush().catch(console.error); // replace with your app's error reporter
}
// on logout:
async function onLogout() {
await fetch("/api/push/unregister", {
method: "POST",
body: JSON.stringify({ token: push.currentToken }),
});
await push.logout();
}
Send updates to tenants
Send an email to every tenant from notification-service@qure.ai.
Create Application
The application name becomes the tenant ID and cannot be changed later.
Application created
Copy this credential now — it will not be shown again.
Store this key securely (e.g. a secrets manager). If lost, you'll need to rotate the credential to get a new one.