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:
- Client calls
requestPermission(); the host shows a native consent drawer - User decides; the host records the consent for your mini app
- Backend POSTs a send request to the Notification Service, authenticated with your API key
- Notification Service delivers the push — the title is always your mini app’s name, the body is your message text
- 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-reactinstalled andAlienProviderwrapping 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 varALIEN_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
| Property | Type | Description |
|---|---|---|
requestPermission | () => Promise<NotificationPermissionResult> | Prompt for permission |
status | NotificationPermissionStatus | null | Last resolved status, or null |
isLoading | boolean | A request is in progress |
error | BridgeError | null | Bridge error from the last failed request |
callable | boolean | Host supports the notifications permission method |
Options
useNotificationPermission({ timeout: 60000 });| Option | Type | Default | Description |
|---|---|---|---|
timeout | number | 120000 | Request timeout in ms |
The default is 2 minutes because the host’s consent drawer is user-driven — give people time to decide.
Permission Statuses
| Status | Meaning |
|---|---|
granted | The user allowed notifications for this mini app |
denied | The user dismissed the consent prompt |
rate_limited | The 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.comAuthentication. 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
| Field | Type | Required | Description |
|---|---|---|---|
sessionAddress | string | Yes | Recipient; ≤ 64 chars. Must come from the verified token’s sub claim |
body | string | Yes | Message text, 1–178 chars. The push title is always your mini app’s name |
startParam | string | No | Deep-link payload, ≤ 256 UTF-8 bytes (see Deep Linking) |
test | string | No | Test scenario (see Test Mode) |
An optional Idempotency-Key header makes retries safe (see
Idempotency).
Security: derive
sessionAddressfrom the verified token. Take it from thesubclaim 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.
Consent
A send requires consent already granted for the
(sessionAddress, your app) pair:
- No consent recorded →
404code1005. The user has never granted permission to your app — prompt them in-app withrequestPermission()first. - Consent recorded but not granted (denied) →
403code1004.
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 payload →
202replaying the originalnotificationId(no second push). - Same key + different payload →
409code1003.
Error codes
Every error uses the envelope { code, message, details? }.
details.retryAfterSeconds is present only on 429.
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 400 | 1001 | Invalid request payload | Fix the field that failed validation |
| 400 | 1002 | Duplicate body within 24h | Vary the message text |
| 401 | 401 | Missing/invalid key, or an Authorization header was sent | Send a valid ank_… key, no bearer token |
| 403 | 1004 | Consent recorded but not granted | The user denied — do not retry |
| 404 | 1005 | No consent recorded for this pair | Prompt the user in-app first |
| 409 | 1003 | Idempotency-Key reused with a different payload | Use a fresh key |
| 413 | 413 | Payload too large (body > 178 chars or startParam > 256 bytes) | Shorten the field |
| 429 | 429 | Send rate limit exceeded | Retry after details.retryAfterSeconds |
| 500 | 500 | Internal error | Retry 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 offstartParamduring your app’s initialization.
Webhook Setup
Register a webhook so the Notification Service can report delivery outcomes to your backend. In the Developer Portal :
- Open Mini Apps → your app → Notifications. The webhook controls appear on this tab only after notifications are enabled — the Start step in Prerequisites
- Click Create webhook
- Set the Webhook URL to a publicly reachable HTTPS endpoint (≤ 2048 chars). Private, loopback, and link-local addresses are refused.
- 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:
| Field | Type | Description |
|---|---|---|
notificationId | string | The ntf_… (or ntf_test_…) id from the send response |
status | string | Delivery outcome (see Webhook Statuses) |
sessionAddress | string | The recipient |
test | boolean | true for test-mode sends, false otherwise |
timestamp | string | RFC3339, 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
- Read the raw request body as a UTF-8 string
- Read
X-Alien-Signature(hex Ed25519) andX-Alien-Timestamp(decimal Unix seconds) - Build the signing input:
`${timestamp}.${rawBody}` - 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
| Status | Meaning |
|---|---|
delivered | The push reached the device |
clicked | The user tapped the notification |
suppressed_user_in_app | The user was inside your mini app; the banner was suppressed |
dropped_permission_revoked | The user turned notifications off |
dropped_device_unreachable | No 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 value | Webhook(s) emitted |
|---|---|
delivered | delivered |
clicked | delivered, then clicked (two webhooks) |
suppressed_user_in_app | suppressed_user_in_app |
dropped_permission_revoked | dropped_permission_revoked |
dropped_device_unreachable | dropped_device_unreachable |
Error scenarios — simulate the matching HTTP error with no push and no webhook:
test value | Result |
|---|---|
error:permission_denied | 403 code 1004 |
error:duplicate_body | 400 code 1002 |
error:rate_limited | 429 with retryAfterSeconds |
error:session_address_not_found | 404 code 1005 |
error:unknown | 500 |
Any other
testvalue is treated as a normal, real send. A typo likedelivereis 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
- Authentication — verify auth tokens on your backend
- Payments — accept Aliencoin payments in your app
- Bridge Reference — call methods and events directly
- React SDK Overview — browse all hooks
- Developer Portal — enable notifications and webhooks