Subscribe to Web Push
Register a W3C PushSubscription with the server. Endpoint-keyed upsert handles browser key regeneration AND device handover (different user on the same browser → reassign).
POST /api/v1/notifications/webpush/subscribe — 🔑 Bearer
Registers a W3C PushSubscription (the object returned by pushManager.subscribe() in the browser ServiceWorker). Endpoint-keyed upsert: if the same endpoint already exists in the DB, the keys are updated and (if the user changed) the row is reassigned to the new user. This handles two real-world cases — (1) the browser regenerated the keypair, (2) a different user logged in on the same browser/device.
Browser side: get a subscription first. This endpoint expects fields produced by serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: <vapid-public-key> }). The platform's VAPID public key is delivered through a separate config / env channel — clients embed it at build time. The server doesn't return it from this endpoint.
Cross-user reassignment is intentional. If the same physical device hosts two user logins over time, the browser generates one push subscription endpoint per browser profile. When user B subscribes on a browser where user A already had a subscription with the same endpoint, the row's userId is silently reassigned from A to B (with fresh p256dh / auth keys). User A no longer receives notifications on that device — they need to re-subscribe from their own profile.
Request
Body — WebPushSubscribeDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
endpoint | string | ✓ | IsNotEmpty() | The subscription.endpoint URL (provider-specific — varies by browser/OS) |
p256dh | string | ✓ | IsNotEmpty() | Base64-encoded public key from subscription.getKey('p256dh') |
auth | string | ✓ | IsNotEmpty() | Base64-encoded auth secret from subscription.getKey('auth') |
userAgent | string | optional | IsString() | Browser/OS UA string for support trails (helps "which device is this?") |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK
{
"success": true,
"subscriptionId": "ws1a2b3c4-d5e6-7890-abcd-ef1234567890"
}| Field | Type | Notes |
|---|---|---|
success | boolean | Always true on 200 |
subscriptionId | string (UUID) | The WebPushSubscription.id (existing-and-updated OR newly-created) |
Note: the response is not wrapped in ApiResponseOf<T> — the controller returns the object directly. The shape above is what you actually get.
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | Missing endpoint / p256dh / auth |
401 | (guard) | Missing / invalid bearer token |
Side effects
prisma.webPushSubscription.findUnique({ where: { endpoint } }).- Existing row —
update({ id: existing.id, data: { userId, p256dh, auth, userAgent, active: true } }).userIdis overwritten even if it differs (cross-user reassignment). - New row —
create({ id: randomUUID(), userId, endpoint, p256dh, auth, userAgent, active: true }). - Audit log:
[webpush] Updated/Created subscription <id> for user <userId>. - Return
{ success: true, subscriptionId: id }.
Code samples
curl -X POST https://api.bio.re/api/v1/notifications/webpush/subscribe \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"endpoint": "https://push.example.com/abc123...",
"p256dh": "BHV1...",
"auth": "8Pgv...",
"userAgent": "Mozilla/5.0 ..."
}'type WebPushSubscribeInput = {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string;
};
async function subscribeWebPush(
accessToken: string,
input: WebPushSubscribeInput,
): Promise<string> {
const res = await fetch('https://api.bio.re/api/v1/notifications/webpush/subscribe', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json?.error?.message ?? 'Web push subscribe failed');
}
return json.subscriptionId as string;
}// 1. Register service worker (typically in app boot)
const registration = await navigator.serviceWorker.register('/sw.js');
// 2. Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
// 3. Subscribe to push (VAPID public key from your platform config)
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// 4. Extract keys + send to platform
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
if (!buffer) return '';
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
const subscriptionId = await subscribeWebPush(accessToken, {
endpoint: subscription.endpoint,
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth')),
userAgent: navigator.userAgent,
});
console.log('Web push subscription registered:', subscriptionId);Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
curl -X POST "https://loading/api/v1/notifications/webpush/subscribe" \ -H "Content-Type: application/json" \ -d '{}'Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/notification/webpush.controller.ts | 37–53 (subscribe) |
| DTO (request) | apps/api-core/src/modules/notification/webpush.controller.ts | 7–19 (WebPushSubscribeDto — inline in controller file) |
| Service | apps/api-core/src/modules/notification/webpush.service.ts | 16–52 (subscribe — endpoint-keyed upsert with cross-user reassignment) |
| Prisma model | packages/prisma/prisma/schema.prisma | WebPushSubscription (@@unique([endpoint]), userId, p256dh, auth, userAgent, active) |
| W3C spec | — | Push API — pushManager.subscribe() |
Email Unsubscribe (Public)
Public HMAC-token endpoint reachable from email footers without login. Adds the email to the suppression list AND disables the email channel in every event preference for the matching user.
Unsubscribe Web Push
Soft-deactivate a single web push subscription by endpoint. Ownership-checked. Server keeps the row (active=false) for forensic / re-subscribe-detection.