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() |
One-Click Unsubscribe (Gmail / Yahoo 2024)
RFC 8058 List-Unsubscribe-Post handler. Receives the `List-Unsubscribe=One-Click` form-urlencoded POST that Gmail and Yahoo 2024 mail clients send when the recipient clicks the inline unsubscribe button. Mirrors the public GET handler's side effects.
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.