Notifications API
The notifications API provides endpoints for managing in-app notifications, push subscriptions, email tracking, chat notification events, and Resend webhook processing.
Source: src/api/routes/notifications/index.ts
Base Path: /api/notifications
Endpoints Overview
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | / | Bearer | List notifications |
GET | /unread-count | Bearer | Get unread count |
GET | /counts | Bearer | Whole-inbox totals |
POST | /read-all | Bearer | Mark all/category as read |
POST | /read-group | Bearer | Mark a bundle as read |
POST | /archive-group | Bearer | Archive a bundle |
POST | /unarchive-all | Bearer | Restore the whole archive |
POST | /:id/read | Bearer | Mark single as read |
POST | /:id/unread | Bearer | Return single to unread |
POST | /:id/archive | Bearer | Archive notification |
POST | /:id/unarchive | Bearer | Restore notification |
DELETE | /:id | Bearer | Delete notification |
POST | /events/chat | Optional | Publish chat notifications |
POST | /guest-read | Token | Mark guest notifications read |
GET | /push-subscriptions | Bearer | List push subscriptions |
POST | /push-subscriptions | Bearer | Create push subscription |
DELETE | /push-subscriptions | Bearer | Revoke by endpoint |
DELETE | /push-subscriptions/:id | Bearer | Revoke by ID |
POST | /push/click | None | Record push click |
GET | /email/open/:token | None | Email open tracking |
GET | /email/click/:token | None | Email click tracking |
GET | /email/unsubscribe/:token | None | Email unsubscribe |
POST | /webhooks/resend | Signature | Resend webhook handler |
Notification Management
List Notifications
GET /api/notifications?limit=25&cursor=abc&category=episode&unreadOnly=trueAuth: requireAuth()
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit | number (1-100) | 25 | Page size |
cursor | string | -- | Cursor for pagination |
archivedOnly | boolean | false | The archive instead of the inbox |
unreadOnly | boolean | false | Unread rows only |
category | episode | collaboration | team | booking | system | -- | One category |
priority | low | normal | immediate | -- | One priority |
Scopes compose (unreadOnly=true&priority=immediate is the bell's Priority tab). An unrecognised category or priority is a 400.
Scoping is server-side on purpose. Every count endpoint reports whole-inbox totals, so a client that filtered one global page against them contradicted itself as soon as a scope's matches fell outside the newest page: an "Episodes 5" rail badge above a "No Episodes notifications" empty state, a bell reporting unread while its Unread tab showed the caught-up state, or an archive that looked empty because the newest page happened to be all active rows.
Response:
{
"success": true,
"data": {
"notifications": [
{
"id": "uuid",
"type": "chat.message",
"category": "collaboration",
"title": "New message",
"body": "Alice sent a message...",
"link": "/p/my-podcast/e/episode-1",
"read_at": null,
"archived_at": null,
"created_at": "2026-04-07T10:00:00Z",
"metadata": { ... }
}
],
"cursor": "next-page-cursor",
"hasMore": true
}
}Returns notifications newest first. Only shows due notifications (unscheduled or scheduled_for <= now), which is the same visibility rule /unread-count and /counts apply. archivedOnly is exempt: an archived row is history, and withholding one for not being due yet serves nobody.
Get Unread Count
GET /api/notifications/unread-countAuth: requireAuth()
Response:
{ "success": true, "data": { "unreadCount": 5 } }Counts unread, non-archived notifications that are due or unscheduled.
Inbox Counts
GET /api/notifications/countsAuth: requireAuth()
Response:
{
"success": true,
"data": {
"inbox": 240,
"unread": 31,
"archived": 12,
"needsAttention": 4,
"byCategory": {
"episode": 5,
"collaboration": 9,
"team": 1,
"booking": 220,
"system": 5
}
}
}Server totals for the whole inbox, for the notifications-page rail and list header. The page loads 25 rows at a time, so counting the loaded array instead understates every figure past the first page and disagrees with the bell badge, which has always been server-counted.
Every inbox-describing figure applies the same visibility rule as / and /unread-count: archived_at IS NULL and scheduled_for IS NULL OR <= now(). archived is the complement (archived_at IS NOT NULL). needsAttention is unread plus priority = 'immediate', deliberately with no "today" qualifier: an unread urgent notification from last week still needs attention, and scoping it to today would need the viewer's timezone on a server that has no reason to know it.
Implemented as nine parallel head: true count queries rather than one RPC. requireAuth() puts a service-role client on the context, so auth.uid() is NULL inside a SECURITY INVOKER function and every query must state user_id = user.id itself. idx_notifications_user_inbox and idx_notifications_user_unread cover them.
unreadByCategory is deliberately absent — it would add five more count queries per call, and the client states a category inbox's size without claiming how much of it is unread rather than reporting the loaded page as truth.
Mark All As Read
POST /api/notifications/read-all?category=collaborationAuth: requireAuth()
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
category | episode | collaboration | team | booking | No | Filter by category |
Response:
{ "success": true, "data": { "markedCount": 12 } }Marks unread, non-archived, due notifications as read. If category is provided, only marks notifications in that category.
Mark a Bundle As Read
POST /api/notifications/read-groupAuth: requireAuth()
Body:
| Field | Type | Required | Description |
|---|---|---|---|
groupKey | string | Yes | 1 to 200 chars. The bundle's group_key |
link | string | null | Yes | The bundle's shared destination, or explicit null |
Response:
{ "success": true, "data": { "markedCount": 7 } }Marks every unread, non-archived, due row matching user_id + group_key + link, which is exactly the client's bundle key (see In-App Bundling). That reaches members the client has not paginated to.
link is .nullable() but never .optional(): omitting it is a 400, so a caller cannot silently fall back to matching every destination under the group key. A null link matches via IS NULL.
Always 200, never 404. An idempotent re-click, a stale client, and a cross-tenant probe all return markedCount: 0. For a group, "nothing left to mark" is success, and the user_id filter is what makes a zero count safe to return.
Archive a Bundle
POST /api/notifications/archive-groupAuth: requireAuth()
Same body and same (user_id, group_key, link) scope as read-group. Sets archived_at, read_at and updated_at on every non-archived member.
Response:
{ "success": true, "data": { "archivedCount": 7 } }The link scoping matters more here than for reads: a coarse group_key-only archive would remove a sibling bundle from the inbox whose destination the user never visited.
Restore the Whole Archive
POST /api/notifications/unarchive-allAuth: requireAuth()
Response:
{ "success": true, "data": { "restoredCount": 12 } }Clears archived_at on every archived row for the caller. Counted via update(..., { count: 'exact' }) rather than returning the rows: no retention sweep exists, so an archive grows without bound and the caller only needs the number for its confirmation toast.
read_at is left as archiving set it. Archiving stamps a row read, so a restore that cleared read_at would resurrect unread state the user had already dealt with and inflate the bell badge.
Mark Single As Read
POST /api/notifications/:id/readAuth: requireAuth()
Response: { "success": true } or 404 if not found.
Return Single To Unread
POST /api/notifications/:id/unreadAuth: requireAuth()
Clears read_at on a row matching user_id + id that is read and not archived. Archived rows are excluded on purpose: they are not on screen, so returning one to unread would raise the bell badge for something the user cannot see. Restore it first.
Response: { "success": true }, or 404 when the row is already unread, archived, or belongs to another user. A malformed id is a 400 from the UUID param schema, never a 500.
There is no group-scoped equivalent. Read and archive are bundle-scoped so they reach members below the current page; un-reading only a bundle's primary would leave it in a state its own badge cannot describe, so the client offers this action on single notifications only.
Archive Notification
POST /api/notifications/:id/archiveAuth: requireAuth()
Archives and marks as read in one operation.
Response: { "success": true } or 404 if not found.
Restore Notification
POST /api/notifications/:id/unarchiveAuth: requireAuth()
Clears archived_at on an archived row matching user_id + id. read_at is untouched, for the same reason as /unarchive-all.
Response: { "success": true }, or 404 when the row is not archived or belongs to another user.
Delete Notification
DELETE /api/notifications/:idAuth: requireAuth()
Calls the archive_notification RPC function.
Response: { "success": true } or 404 if not found.
Chat Notification Events
Publish Chat Message Notifications
POST /api/notifications/events/chatAuth: optionalAuth() -- Accepts Bearer token OR guest access token in body.
Request Body:
{
"episodeId": "uuid",
"messageId": "msg-id",
"accessToken": "guest-token-optional",
"activeUserIds": ["uuid", "uuid"],
"activeGuestIds": ["uuid", "uuid"]
}| Field | Type | Required | Description |
|---|---|---|---|
episodeId | UUID | Yes | Episode containing the chat |
messageId | string | Yes | Message that triggered notification |
accessToken | string (min 10) | No | Guest access token (for guest senders) |
activeUserIds | UUID[] | No | Users currently viewing chat (suppress notifications) |
activeGuestIds | UUID[] | No | Guests currently viewing chat (suppress notifications) |
Validation:
- Authenticated users: verifies podcast membership
- Guest senders: validates access token matches the episode
- Fetches message content and detects @mentions
Response:
{
"success": true,
"data": { "notifiedCount": 3, "dedupedCount": 1 }
}Mark Guest Notifications Read
POST /api/notifications/guest-readAuth: None (guest token in body).
Request Body:
{
"episodeId": "uuid",
"accessToken": "guest-access-token"
}Marks all unread notifications for the guest in the specified episode as read.
Response: { "success": true }
Push Subscriptions
List Subscriptions
GET /api/notifications/push-subscriptionsAuth: requireAuth()
Response:
{
"success": true,
"data": {
"subscriptions": [
{
"id": "uuid",
"endpoint": "https://fcm.googleapis.com/...",
"device_label": "Chrome on MacBook",
"user_agent": "Mozilla/5.0...",
"is_active": true,
"last_used_at": "2026-04-07T10:00:00Z",
"created_at": "2026-03-01T10:00:00Z"
}
]
}
}Create/Upsert Subscription
POST /api/notifications/push-subscriptionsAuth: requireAuth()
Request Body:
{
"subscription": {
"endpoint": "https://fcm.googleapis.com/...",
"expirationTime": null,
"keys": {
"p256dh": "base64url-encoded-key",
"auth": "base64url-encoded-secret"
}
},
"deviceLabel": "Chrome on MacBook",
"userAgent": "Mozilla/5.0..."
}Upserts on endpoint -- if a subscription with the same endpoint exists, it's reactivated. Falls back to the User-Agent header if userAgent is not provided.
Response: 201 with { "success": true, "data": { "subscription": { ... } } }
Revoke by Endpoint
DELETE /api/notifications/push-subscriptions?endpoint=https://...Auth: requireAuth()
Marks the subscription as inactive and sets last_failure_at.
Revoke by ID
DELETE /api/notifications/push-subscriptions/:idAuth: requireAuth()
Same behaviour as revoke by endpoint.
Push Click Tracking
Record Push Click
POST /api/notifications/push/clickAuth: None (called from service worker).
Request Body:
{
"deliveryId": "uuid",
"notificationId": "uuid"
}Updates the delivery record status to clicked.
Response: { "success": true }
Email Tracking
Open Tracking Pixel
GET /api/notifications/email/open/:tokenAuth: None.
Returns a 1x1 transparent GIF. Updates delivery status to opened.
Token must be valid, open type, and not expired. Consumed tokens are ignored (no error).
Click Tracking
GET /api/notifications/email/click/:tokenAuth: None.
Returns HTTP 302 redirect to the original destination URL. Updates delivery status to clicked.
Falls back to app homepage if destination URL is missing.
Unsubscribe
GET /api/notifications/email/unsubscribe/:tokenAuth: None.
Disables the specific notification type in user preferences and returns an HTML confirmation page. For example, an unsubscribe token for a booking.requested notification sets email_new_booking = false.
Resend Webhooks
Webhook Handler
POST /api/notifications/webhooks/resendAuth: Svix signature verification via RESEND_WEBHOOK_SECRET.
Rate Limit: 300 requests/minute per IP.
Webhook Types:
| Resend Event | Delivery Status | Timestamp |
|---|---|---|
email.sent | sent | sent_at |
email.delivered | delivered | delivered_at |
email.opened | opened | opened_at |
email.clicked | clicked | clicked_at |
email.bounced | bounced | failed_at |
email.complained | failed | failed_at |
email.failed | failed | failed_at |
The handler:
- Verifies the Svix signature
- Extracts
email_idfrom the webhook payload - Finds the matching delivery record by
provider_message_id - Updates status (never downgrades -- see status hierarchy)
- Sets the appropriate timestamp
Error Responses
All endpoints follow the standard error format:
{ "error": "Description of what failed" }| Status | Meaning |
|---|---|
| 400 | Invalid request body or parameters |
| 401 | Missing or invalid authentication |
| 403 | Insufficient permissions |
| 404 | Notification or subscription not found |
| 500 | Internal server error |
Related Documentation
- Notification System -- System overview and architecture
- Push Notifications -- Web Push implementation details
- Email Tracking -- Tracking token system
- Delivery Pipeline -- How notifications are scheduled and executed