Skip to Content
React SDKNotifications
View .md

Push Notifications in Alien Mini Apps: React SDK Guide

Your mini app asks the user for push-notification permission on the client with useNotificationPermission. Once the user grants it, your backend sends pushes through the Alien Notification Service, and the service POSTs signed status webhooks back to your backend reporting each delivery outcome.

Available since contract version 1.5.0.

How Notifications Work

Notifications span the client, the host, your backend, and the Alien Notification Service:

  1. Client calls requestPermission(); the host shows a native consent drawer
  2. User decides; the host records the consent for your mini app
  3. Backend POSTs a send request to the Notification Service, authenticated with your API key
  4. Notification Service delivers the push — the title is always your mini app’s name, the body is your message text
  5. Notification Service POSTs a signed status webhook to your backend reporting the outcome (delivered, clicked, dropped, …)

The permission the user grants in the consent drawer is exactly the consent the service checks at send time: a send for a (sessionAddress, your app) pair that never granted permission is rejected (see Consent).

Prerequisites and Setup

  • Your mini app registered in the Developer Portal.
  • @alien-id/miniapps-react installed and AlienProvider wrapping your app; your backend also needs @alien-id/miniapps-auth-client (see Authentication).
  • Notifications enabled for your app, which issues an API key. In the Portal go to Mini Apps → your app → Notifications and click Start. The key (format ank_…) is shown once — store it as the server-side env var ALIEN_NOTIFICATIONS_KEY.
  • A backend endpoint that verifies the user’s auth token and proxies send requests (clients must never hold the API key).
  • Optionally, a status webhook so your backend learns each delivery outcome.

Quick Start

This gets you from permission to a delivered push.

1. Request permission on the client. The host shows the consent drawer; on granted you can start sending.

import { useNotificationPermission, useAlien } from '@alien-id/miniapps-react'; function EnableNotifications() { const { authToken } = useAlien(); const { requestPermission, status, isLoading, callable } = useNotificationPermission(); if (!callable) return null; return ( <button disabled={isLoading || !authToken} onClick={async () => { const result = await requestPermission(); if (result.ok && result.status === 'granted') { // Permission granted — sends will now be accepted. } }} > {status === 'granted' ? 'Notifications on' : 'Enable notifications'} </button> ); }

2. Add a backend send route. Verify the user’s auth token (see Authentication), take sessionAddress from the verified token’s sub claim (never from the client request), then call the Notification Service.

import { createAuthClient } from '@alien-id/miniapps-auth-client'; import { NextResponse } from 'next/server'; const authClient = createAuthClient({ // Your provider address from the Developer Portal audience: 'your-miniapp-provider-address', }); export async function POST(request: Request) { const token = request.headers .get('Authorization') ?.replace('Bearer ', ''); if (!token) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // `sub` is the session address — the notification recipient. const { sub: sessionAddress } = await authClient.verifyToken(token); const { body } = await request.json(); const res = await fetch( `${process.env.ALIEN_NOTIFICATIONS_BASE_URL}/api/v1/notifications/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Alien-Notifications-Key': process.env.ALIEN_NOTIFICATIONS_KEY!, }, body: JSON.stringify({ sessionAddress, body }), }, ); return NextResponse.json(await res.json().catch(() => ({})), { status: res.status, }); }

A successful send returns 202 with { notificationId, status: "queued" }.

3. Call your route from the client with the bearer token from useAlien().

const { authToken } = useAlien(); await fetch('/api/notifications/send', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ body: 'Your order has shipped!' }), });

queued means the service accepted the request; the actual delivery outcome arrives later as a status webhook.

Seeing no push? A send to yourself while your mini app is open is suppressed (suppressed_user_in_app) — no banner shows. Close the app before the send fires, or use Test Mode to verify your route and webhook handler end to end without a real delivery.

Requesting Permission

The useNotificationPermission hook drives the client-side consent flow. The host shows a native consent drawer and returns the resulting status.

import { useNotificationPermission } from '@alien-id/miniapps-react'; const { requestPermission, status, isLoading, callable } = useNotificationPermission();

requestPermission() resolves a discriminated result so you can tell a host decision apart from a bridge failure:

type NotificationPermissionResult = | { ok: true; status: NotificationPermissionStatus } | { ok: false; error: BridgeError };

Return Values

PropertyTypeDescription
requestPermission() => Promise<NotificationPermissionResult>Prompt for permission
statusNotificationPermissionStatus | nullLast resolved status, or null
isLoadingbooleanA request is in progress
errorBridgeError | nullBridge error from the last failed request
callablebooleanHost supports the notifications permission method

Options

useNotificationPermission({ timeout: 60000 });
OptionTypeDefaultDescription
timeoutnumber120000Request timeout in ms

The default is 2 minutes because the host’s consent drawer is user-driven — give people time to decide.

Permission Statuses

StatusMeaning
grantedThe user allowed notifications for this mini app
deniedThe user dismissed the consent prompt
rate_limitedThe host suppressed the prompt (see below)

Prompt rate limiting

To protect users, the host caps how often a mini app can prompt: at most 3 prompts per rolling 24 hours (per session address × mini app). When the budget is exhausted, requestPermission() resolves with rate_limited without showing any UI, and the user’s stored consent is left unchanged. Treat rate_limited as “ask again later,” not as a denial.

Prompt budget ≠ send limits. This 3-prompts-per-24h cap limits how often you can ask for permission. It is unrelated to the send limits below, which cap how many notifications your backend may deliver to an already-consenting user.

In-flight requests

Calling requestPermission() while a previous call is still pending resolves immediately with { ok: false, error } where error is a BridgeBusyError — the in-flight request is left untouched and no state is changed. Guard your UI with isLoading to avoid double-prompting.

Sending Notifications

Sends are made by your backend, never the client, because they carry your secret API key. Each request targets one session address.

Your backend calls the Notification Service at its production base URL. Set it as the server-side env var ALIEN_NOTIFICATIONS_BASE_URL:

https://miniapp-notify.alien-api.com

Authentication. Send the ank_… key in the X-Alien-Notifications-Key header. These routes reject any request that carries an Authorization header with 401 — the API key is the only accepted credential here.

POST /api/v1/notifications/send

FieldTypeRequiredDescription
sessionAddressstringYesRecipient; ≤ 64 chars. Must come from the verified token’s sub claim
bodystringYesMessage text, 1–178 chars. The push title is always your mini app’s name
startParamstringNoDeep-link payload, ≤ 256 UTF-8 bytes (see Deep Linking)
teststringNoTest scenario (see Test Mode)

An optional Idempotency-Key header makes retries safe (see Idempotency).

Security: derive sessionAddress from the verified token. Take it from the sub claim of the auth token you verified server-side — never from the client request body. Trusting a client-supplied address would let any user push notifications to any other user.

A successful send returns 202:

{ "notificationId": "ntf_01J9Z8X3QK7M2N4P6R8T0V2W4Y", "status": "queued" }

notificationId is ntf_… for real sends and ntf_test_… for test-mode sends. status is always queued: the request was accepted, and the delivery outcome arrives via webhook.

A send requires consent already granted for the (sessionAddress, your app) pair:

  • No consent recorded404 code 1005. The user has never granted permission to your app — prompt them in-app with requestPermission() first.
  • Consent recorded but not granted (denied) → 403 code 1004.

The practical pattern: store the session address server-side the moment requestPermission() resolves granted, then only send to stored addresses.

To check consent without sending, call:

GET /api/v1/notifications/consent?sessionAddress=…

with the same X-Alien-Notifications-Key header. It returns 200:

{ "sessionAddress": "…", "status": "prompt" }

status is prompt, granted, or denied. An unknown user returns prompt (never 404) — prompt means “no consent recorded yet.”

Send limits

Per (your app × session address), the service enforces 1 send per hour AND 3 sends per day. Exceeding either returns 429 with details.retryAfterSeconds telling you when to retry. Failed sends do not burn quota.

Separately, sending an identical body to the same user within 24 hours returns 400 code 1002 (duplicate_body) — vary your message text. This is distinct from the rate limit above and from the prompt budget.

Idempotency

Pass an optional Idempotency-Key header to make a send safely retryable. The key is scoped per (your app, key) for a 24-hour window:

  • Same key + same payload202 replaying the original notificationId (no second push).
  • Same key + different payload409 code 1003.

Error codes

Every error uses the envelope { code, message, details? }. details.retryAfterSeconds is present only on 429.

HTTPcodeMeaningWhat to do
4001001Invalid request payloadFix the field that failed validation
4001002Duplicate body within 24hVary the message text
401401Missing/invalid key, or an Authorization header was sentSend a valid ank_… key, no bearer token
4031004Consent recorded but not grantedThe user denied — do not retry
4041005No consent recorded for this pairPrompt the user in-app first
4091003Idempotency-Key reused with a different payloadUse a fresh key
413413Payload too large (body > 178 chars or startParam > 256 bytes)Shorten the field
429429Send rate limit exceededRetry after details.retryAfterSeconds
500500Internal errorRetry later

Deep Linking with startParam

Pass startParam on a send to deep-link the user into a specific place when they tap the notification. The host injects this value as a launch param when it (re-)opens your mini app from the tap.

Read it on the client:

import { useLaunchParams } from '@alien-id/miniapps-react'; const startParam = useLaunchParams()?.startParam;

Outside React, use getLaunchParams()?.startParam from @alien-id/miniapps-bridge.

Launch params are injected once at boot and never change at runtime. A notification tap re-launches your app with the new startParam; there is no in-session event for a new value. Route off startParam during your app’s initialization.

Webhook Setup

Register a webhook so the Notification Service can report delivery outcomes to your backend. In the Developer Portal:

  1. Open Mini Apps → your app → Notifications. The webhook controls appear on this tab only after notifications are enabled — the Start step in Prerequisites
  2. Click Create webhook
  3. Set the Webhook URL to a publicly reachable HTTPS endpoint (≤ 2048 chars). Private, loopback, and link-local addresses are refused.
  4. Click Create

You are then shown your webhook’s Ed25519 public key (hex). The Portal displays it only at creation (and again after a key rotation) — copy it now and store it server-side as ALIEN_NOTIFICATIONS_WEBHOOK_KEY; the handler below reads it from there.

Webhook Payload

The service POSTs a JSON body to your URL:

FieldTypeDescription
notificationIdstringThe ntf_… (or ntf_test_…) id from the send response
statusstringDelivery outcome (see Webhook Statuses)
sessionAddressstringThe recipient
testbooleantrue for test-mode sends, false otherwise
timestampstringRFC3339, stamped per delivery attempt

Webhook Signature Verification

Security: always verify the webhook signature. Without it, anyone could POST fake delivery events to your endpoint. Reject any request with a missing or invalid signature.

Every request carries two headers:

  • X-Alien-Signature — lowercase hex of an Ed25519 signature.
  • X-Alien-Timestamp — the Unix epoch time in seconds as a decimal string.

The signature is computed over the string X-Alien-Timestamp value + "." + raw request body. Read the raw body before JSON parsing — re-serializing the parsed JSON can change the bytes and break verification.

Coming from payments? This scheme differs from the payment webhook: different header names (X-Alien-Signature / X-Alien-Timestamp), and the signing input is prefixed with the timestamp (`timestamp + ”.”

  • rawBody`), not the raw body alone.

Verification Algorithm

  1. Read the raw request body as a UTF-8 string
  2. Read X-Alien-Signature (hex Ed25519) and X-Alien-Timestamp (decimal Unix seconds)
  3. Build the signing input: `${timestamp}.${rawBody}`
  4. Import your Ed25519 public key (hex) and verify the signature over the signing input

Implementation (Web Crypto API)

async function verifySignature( publicKeyHex: string, signatureHex: string, timestamp: string, rawBody: string, ): Promise<boolean> { const publicKey = await crypto.subtle.importKey( 'raw', Buffer.from(publicKeyHex, 'hex'), { name: 'Ed25519' }, false, ['verify'], ); // Signing input is the timestamp header + "." + raw body const signingInput = `${timestamp}.${rawBody}`; return crypto.subtle.verify( 'Ed25519', publicKey, Buffer.from(signatureHex, 'hex'), Buffer.from(signingInput), ); }

Complete Webhook Handler

A full Next.js App Router route. Read the raw body, require both headers, verify, then switch on status. Respond 2xx fast and process asynchronously.

import { NextResponse } from 'next/server'; // verifySignature() from the previous section export async function POST(request: Request) { // 1. Read the raw body before parsing JSON const rawBody = await request.text(); // 2. Require both signature headers const signature = request.headers.get('x-alien-signature'); const timestamp = request.headers.get('x-alien-timestamp'); if (!signature || !timestamp) { return NextResponse.json( { error: 'Missing signature headers' }, { status: 401 }, ); } // 3. Verify the Ed25519 signature. // During key rotation, verify against the new public key first, // then fall back to the previous one. const isValid = await verifySignature( process.env.ALIEN_NOTIFICATIONS_WEBHOOK_KEY!, signature, timestamp, rawBody, ); if (!isValid) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } // 4. Parse and handle. Make this idempotent. const event = JSON.parse(rawBody); switch (event.status) { case 'delivered': break; case 'clicked': break; case 'suppressed_user_in_app': break; case 'dropped_permission_revoked': break; case 'dropped_device_unreachable': break; default: // Tolerate unknown statuses — the set is open. break; } return NextResponse.json({ ok: true }); }

Webhook Statuses

StatusMeaning
deliveredThe push reached the device
clickedThe user tapped the notification
suppressed_user_in_appThe user was inside your mini app; the banner was suppressed
dropped_permission_revokedThe user turned notifications off
dropped_device_unreachableNo deliverable device for the user

The status set is open — tolerate unknown values. The service sends at most one webhook per (notificationId, status). clicked arrives in addition to delivered, not instead of it.

Delivery and Retries

A delivery succeeds when your endpoint responds with any 2xx. Otherwise the service retries with exponential backoff (base 5s, doubling, capped at 5 minutes, with jitter) for up to 8 attempts, after which the event is dropped. Respond fast and process asynchronously, and make your handling idempotent — a retried delivery can arrive after you have already processed it.

Key Rotation

To rotate the webhook signing key, open the Notifications tab and click Rotate keys. A new keypair starts signing immediately, and the previous public key (previousPublicKey) stays available until the next rotation. During the rollover, verify each request against the new key and fall back to the previous one.

Test Mode

The optional test field on a send simulates an outcome without touching real users. Test sends bypass the consent, rate-limit, and duplicate-body checks, return an ntf_test_… id, and deliver real, signed webhooks (with test: true) so you can exercise your handler end to end.

Success scenarios — deliver the named status as a webhook:

test valueWebhook(s) emitted
delivereddelivered
clickeddelivered, then clicked (two webhooks)
suppressed_user_in_appsuppressed_user_in_app
dropped_permission_revokeddropped_permission_revoked
dropped_device_unreachabledropped_device_unreachable

Error scenarios — simulate the matching HTTP error with no push and no webhook:

test valueResult
error:permission_denied403 code 1004
error:duplicate_body400 code 1002
error:rate_limited429 with retryAfterSeconds
error:session_address_not_found404 code 1005
error:unknown500

Any other test value is treated as a normal, real send. A typo like delivere is not rejected — it sends an actual push. Use only the exact scenario strings above.

Managing Your API Key

Your API key (ank_ + 26 chars) authenticates every send. Keep it secret: store it server-side only, never expose it to the browser. That is why sends go through your backend rather than directly from the client.

To rotate it, open the Notifications tab in the Portal and click Rotate API key. A new key is issued immediately and the old key keeps working for 7 days, then stops. Update ALIEN_NOTIFICATIONS_KEY on your server within the grace window.

Testing

The mock bridge supports the permission flow out of the box — the default response is { status: 'granted' } (the mock host auto-fills reqId). Override it to exercise other paths:

createMockBridge({ handlers: { 'notifications:permission.request': (payload) => ({ status: 'denied', reqId: payload.reqId, }), }, });

Bridge API

For non-React apps, request permission through the bridge directly.

import { request } from '@alien-id/miniapps-bridge'; const result = await request.ifAvailable( 'notifications:permission.request', {}, 'notifications:permission.response', { timeout: 120000 }, ); if (result.ok) { console.log('Status:', result.data.status); }

The explicit timeout: 120000 matters: the raw bridge default is 30 seconds, which is too short for a user-driven consent drawer. The useNotificationPermission hook applies the 2-minute default for you, but raw bridge calls must pass it themselves.

The response payload:

{ reqId: string; status: 'granted' | 'denied' | 'rate_limited'; }

Complete Example

A full flow: a permission gate that, once granted, sends a notification through your own backend route.

Frontend (React)

import { useNotificationPermission, useAlien, } from '@alien-id/miniapps-react'; import { useState } from 'react'; function NotifyButton() { const { authToken } = useAlien(); const { requestPermission, status, callable } = useNotificationPermission(); const [sending, setSending] = useState(false); if (!callable) return <p>Notifications not supported here</p>; // Step 1: gate on permission if (status !== 'granted') { return ( <button onClick={() => requestPermission()}> Enable notifications </button> ); } // Step 2: trigger a send via your own backend route const send = async () => { if (!authToken) return; setSending(true); try { await fetch('/api/notifications/send', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ body: 'Your order has shipped!' }), }); } finally { setSending(false); } }; return ( <button onClick={send} disabled={sending}> {sending ? 'Sending…' : 'Send notification'} </button> ); }

Backend Send Route

A full Next.js App Router route. It verifies the user’s token, derives sessionAddress from the verified sub claim, validates the request body, calls the Notification Service, and proxies the status and body back. See Authentication for the full createAuthClient options and token-claim reference.

import { createAuthClient, JwtErrors } from '@alien-id/miniapps-auth-client'; import { NextResponse } from 'next/server'; import { z } from 'zod'; const authClient = createAuthClient({ audience: 'your-miniapp-provider-address', }); const SendRequest = z.object({ body: z.string().min(1).max(178), // The service caps startParam at 256 UTF-8 *bytes*, not chars startParam: z .string() .refine((s) => new TextEncoder().encode(s).length <= 256) .optional(), }); function extractBearerToken(header: string | null): string | null { return header?.startsWith('Bearer ') ? header.slice(7) : null; } export async function POST(request: Request) { try { const token = extractBearerToken(request.headers.get('Authorization')); if (!token) { return NextResponse.json( { error: 'Missing authorization token' }, { status: 401 }, ); } // `sub` is the session address — the notification recipient. const { sub: sessionAddress } = await authClient.verifyToken(token); const parsed = SendRequest.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json( { error: 'Invalid request', details: parsed.error.flatten() }, { status: 400 }, ); } const { body, startParam } = parsed.data; const res = await fetch( `${process.env.ALIEN_NOTIFICATIONS_BASE_URL}/api/v1/notifications/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Alien-Notifications-Key': process.env.ALIEN_NOTIFICATIONS_KEY!, }, body: JSON.stringify({ sessionAddress, body, ...(startParam ? { startParam } : {}), }), }, ); // Proxy the service's status and body straight back to the client. const nsBody = await res.json().catch(() => ({})); return NextResponse.json(nsBody, { status: res.status }); } catch (error) { if (error instanceof JwtErrors.JWTExpired) { return NextResponse.json({ error: 'Token expired' }, { status: 401 }); } if (error instanceof JwtErrors.JOSEError) { return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); } console.error('Failed to send notification:', error); return NextResponse.json( { error: 'Failed to send notification' }, { status: 500 }, ); } }

Next Steps

Last updated on