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.
GET /api/v1/notifications/unsubscribe โ ๐ Public ยท Rate limit: 10 req / hour
Public unsubscribe link target. Reachable from email footers without login. Validates a server-signed HMAC token (sha256 over the email address, truncated to 32 hex chars), then adds the email to the suppression list and flips email: false in every notification preference for the matching user.
HMAC signature is the auth. The token must equal sha256(UNSUBSCRIBE_HMAC_SECRET + email).slice(0,32) (server-side env). An incorrect token returns 200 OK with success: false (NOT a 403/404 โ to avoid email-enumeration via timing).
Two-pass effect on success:
- Insert into
EmailSuppressionList(idempotent โfindUniquefirst to skip duplicates) withreason: 'user_unsubscribe'. The email-dispatch pipeline checks this list before sending โ once suppressed, no future emails go out for that address. - For every
eventKeyinNotificationDefault, upsert the user'sNotificationPreferencerow withemail: falseand other channels defaulting totrue(on first save). This means the user can still receive in-app / push / SMS โ only email is suppressed.
If no User is found for the email (e.g. email-only subscriber), only step 1 runs.
Request
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
email | string | conditional | The email being unsubscribed. Without it, the response is the friendly "go to settings" message (no error). |
token | string | conditional | HMAC signature. Without it, same friendly fallback message. |
No body, no headers required.
Response
200 OK โ ApiResponseOf<UnsubscribeResponseDto>
Three response shapes depending on input + validity:
Missing email or token (link without params)
{
"success": true,
"data": {
"message": "Please visit your account settings to manage notification preferences."
}
}Invalid token (HMAC mismatch)
{
"success": true,
"data": {
"success": false,
"message": "Invalid or expired unsubscribe link."
}
}Valid token
{
"success": true,
"data": {
"success": true,
"message": "You have been unsubscribed from email notifications."
}
}| Field | Type | Notes |
|---|---|---|
success | boolean | undefined | Present in valid + invalid-token paths; absent in the missing-params path |
message | string | Human-friendly localized message |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
429 | (throttle) | Rate limit exceeded (10 req/hour โ tighter for the public endpoint) |
500 | (env) | UNSUBSCRIBE_HMAC_SECRET not set in production (server-side config error โ surfaces as generic 500 to the user) |
The endpoint does not return 4xx for missing or invalid params โ it always returns 200 with a tailored message body. This is deliberate: email links can't enumerate addresses or distinguish "wrong token" from "wrong address".
Side effects
- Missing
email/tokenโ return friendly fallback message. No mutations. - Compute expected token โ
createHmac('sha256', UNSUBSCRIBE_HMAC_SECRET).update(email).digest('hex').slice(0, 32). In dev mode, falls back to'dev-unsubscribe-secret'if env var is unset. - Token mismatch โ return
{ success: false, message: 'Invalid or expired unsubscribe link.' }. No mutations. - Token valid โ two-pass mutation:
- Suppress email โ
emailSuppressionList.findUnique({ where: { email } }); if missing, create withreason: 'user_unsubscribe',provider: null. Idempotent. - Disable email channel for the user โ
prisma.user.findUnique({ where: { email } })to resolveuserId; if found, iterate everynotificationDefault.findManyrow andnotificationPreference.upsert({ update: { email: false }, create: { email: false, push: true, inApp: true, webPush: true, sms: true } })for eacheventKey.
- Suppress email โ
- Return
{ success: true, message: 'You have been unsubscribed from email notifications.' }.
Code samples
# Direct call (server-generated token)
curl 'https://api.bio.re/api/v1/notifications/[email protected]&token=abc123def456...32chars'
# Missing-params fallback (returns friendly message)
curl 'https://api.bio.re/api/v1/notifications/unsubscribe'type UnsubscribeResponse = {
success?: boolean;
message: string;
};
async function unsubscribeFromEmail(email: string, token: string): Promise<UnsubscribeResponse> {
const url = new URL('https://api.bio.re/api/v1/notifications/unsubscribe');
url.searchParams.set('email', email);
url.searchParams.set('token', token);
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Unsubscribe failed'), {
code: json?.error?.code,
});
}
return json.data;
}Try it
Authorization
bearer In: header
Query Parameters
Response Body
application/json
curl -X GET "https://loading/api/v1/notifications/unsubscribe"{
"success": true,
"data": {
"success": true,
"message": "string"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller (inline impl) | apps/api-core/src/modules/notification/user-notification.controller.ts | 213โ274 (unsubscribe โ HMAC validate + suppression + per-event email-off upsert) |
| DTO (response) | apps/api-core/src/modules/notification/dto/notification-client-response.dto.ts | 98โ104 (UnsubscribeResponseDto) |
| HMAC | Node.js crypto | createHmac('sha256', secret).update(email).digest('hex').slice(0, 32) |
| Env | server config | UNSUBSCRIBE_HMAC_SECRET (production-required; dev fallback 'dev-unsubscribe-secret') |
| Prisma models | packages/prisma/prisma/schema.prisma | EmailSuppressionList (@@unique([email])), NotificationDefault, NotificationPreference, User |
Clear Read Notifications
Bulk-delete every read notification for the calling user. Returns the count actually deleted. Unread rows are untouched.
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).