Notification Service

Sign in with your qure.ai account to access the internal portal.

Sign in
Notification Service Internal Portal
About this service

One service. Four channels.

  • Applications Connect your apps.
  • Observability See every delivery.
  • Example See it with sample data.
  • Documentation Send your first notification.

Applications

Application Name Status Activity · 30 days Created At Updated At
Loading applications…

Observability

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 data These 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.

  • Getting Started
  • Backend SDK
  • Slack
  • Email
  • Push Notifications
  • In-App Notifications
  • Delivery Failures
  • Examples
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.

Download AGENTS.md

Application credential

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.

02 · Backend Notification SDK

Installation

export UV_INDEX_QURE_USERNAME=...
export UV_INDEX_QURE_PASSWORD=...
uv add notify-sdk

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,
)
resp, err := client.SendSlack(ctx, notification.SendSlackRequest{
    Recipients: []string{"#reports"},
    Body:       "This week's export is attached.",
    Attachments: []notification.Attachment{
        {Filename: "export.csv", ContentType: "text/csv", Content: exportBytes},
    },
    IdempotencyKey: exportJobID,
})

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:

{
  "notification_id": "ntf_01M0M0Y2RBAJFQD3Q7BKJ07Q85",
  "status": "accepted"
}

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:

{
  "notification_id": "ntf_01M0M0T96DFDNYQ43PSA3SNQV6",
  "status": "accepted"
}

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.

Download frontend/push-sdk-web ZIP

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.

Download firebase-messaging-sw.js

Initialize after your user login

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": "d3d959aad792436db2f283126827fdf3",
  "status": "ACTIVE"
}

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:

response = client.send_push(
    user_ids=["user_1"],
    title="Deployment completed",
    body="Production deploy finished successfully.",
    idempotency_key=deploy_event_id,
    data={"deploy_id": "12345"},  # optional custom key/value payload
)
print(response.notification_id, response.status)
resp, err := client.SendPush(ctx, notification.SendPushRequest{
    UserIDs:        []string{"user_1"},
    Title:          "Deployment completed",
    Body:           "Production deploy finished successfully.",
    IdempotencyKey: deployEventID,
    Data:           map[string]string{"deploy_id": "12345"}, // optional custom key/value payload
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(resp.NotificationID, resp.Status)

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:

{
  "notification_id": "ntf_01M0M17GYE9A99QX2NW8Z20THX",
  "status": "accepted"
}

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)
status, err := client.UnregisterPushDevice(ctx, notification.UnregisterPushDeviceRequest{
    UserID: currentUser.ID,
    Token:  currentToken,
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(status)

Example response

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

Sending notifications

response = client.send_in_app(
    user_ids=["user_1"],
    title="Ticket updated",       # optional
    body="Your ticket #42 moved to Done.",
    url="https://ryuboard.example.com/tickets/42",  # optional click-through destination
    idempotency_key="ticket-42-done",
)
print(response.notification_id, response.status)

Example response

This is a real response captured from a live send, not a placeholder:

{
  "notification_id": "ntf_01M0M1Q5V3F7T2K9XH6WYB4E8R",
  "status": "accepted"
}

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 CodeMeaning
SLACK_CHANNEL_NOT_FOUNDThe destination channel doesn't exist.
SLACK_CHANNEL_ACCESS_DENIEDThe bot isn't a member of the channel, or the channel is archived/restricted.
SLACK_INVALID_CREDENTIALSThe configured Slack bot token is invalid, revoked, or missing scopes.
SLACK_CREDENTIAL_MISSINGNo Slack bot token is configured for this application or the shared qHive default.
SLACK_RECIPIENT_NOT_FOUNDNo Slack user matched the given email address.
SLACK_MESSAGE_REJECTEDSlack rejected the message itself (too long, empty text).
SLACK_RATE_LIMITEDSlack's API rate limit was hit (retried automatically).
SLACK_ATTACHMENT_SCOPE_MISSINGThe 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_FAILEDThe 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_RECIPIENTThe recipient address was rejected by the provider.
EMAIL_SENDER_NOT_ALLOWEDThe sender identity isn't verified with the email provider.
EMAIL_PROVIDER_REJECTEDThe email provider rejected the request for another reason.
EMAIL_ALL_RECIPIENTS_UNSUBSCRIBEDEvery to recipient has unsubscribed from Email — expected, not a defect; SES is never called.
FCM_INVALID_TOKEN / FCM_TOKEN_UNREGISTEREDThat one device's push token is no longer valid — other devices are unaffected.
PROVIDER_TIMEOUT / PROVIDER_UNAVAILABLE / PROVIDER_RATE_LIMITEDA 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}
import "github.com/qureai/agentic-ai-team-notification-lib/sdk-go/notification"

client, err := notification.NewClient("growth-team", apiKey)
if err != nil {
    log.Fatal(err)
}
defer client.Close()

ctx := context.Background()

client.SendSlack(ctx, notification.SendSlackRequest{
    Recipients:     []string{"#alerts"},
    Body:           "Nightly job finished with 3 warnings.",
    IdempotencyKey: fmt.Sprintf("nightly-job-%s", runID),
})

client.SendEmail(ctx, notification.SendEmailRequest{
    To:             []string{"ops@qure.ai"},
    Subject:        "Weekly digest",
    Body:           "Attached is this week's usage summary.",
    IdempotencyKey: fmt.Sprintf("weekly-digest-%s", weekID),
})

client.SendPush(ctx, notification.SendPushRequest{
    UserIDs:        []string{"user_1"},
    Title:          "Deployment completed",
    Body:           "Production deploy finished successfully.",
    IdempotencyKey: fmt.Sprintf("deploy-%s", deployID),
})

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.

Lowercase letters, digits, and single hyphens only. Immutable once created.
Leave blank to use the shared qHive default bot token.
notifier- @qure.ai
Leave blank to default to notifier-<name>@qure.ai. Both fields are optional and can 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.

Are you sure?

Edit channel configuration

For security, the current token is never shown here. Leave blank to keep it unchanged, or type a new one to replace it.
Clearing this falls back to the shared qHive default bot token.
notifier- @qure.ai
Clearing this falls back to notifier-<application_name>@qure.ai.

Logs

Loading…