Skip to content

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

MethodPathAuthPurpose
GET/BearerList notifications
GET/unread-countBearerGet unread count
GET/countsBearerWhole-inbox totals
POST/read-allBearerMark all/category as read
POST/read-groupBearerMark a bundle as read
POST/archive-groupBearerArchive a bundle
POST/unarchive-allBearerRestore the whole archive
POST/:id/readBearerMark single as read
POST/:id/unreadBearerReturn single to unread
POST/:id/archiveBearerArchive notification
POST/:id/unarchiveBearerRestore notification
DELETE/:idBearerDelete notification
POST/events/chatOptionalPublish chat notifications
POST/guest-readTokenMark guest notifications read
GET/push-subscriptionsBearerList push subscriptions
POST/push-subscriptionsBearerCreate push subscription
DELETE/push-subscriptionsBearerRevoke by endpoint
DELETE/push-subscriptions/:idBearerRevoke by ID
POST/push/clickNoneRecord push click
GET/email/open/:tokenNoneEmail open tracking
GET/email/click/:tokenNoneEmail click tracking
GET/email/unsubscribe/:tokenNoneEmail unsubscribe
POST/webhooks/resendSignatureResend webhook handler

Notification Management

List Notifications

GET /api/notifications?limit=25&cursor=abc&category=episode&unreadOnly=true

Auth: requireAuth()

Query Parameters:

ParamTypeDefaultDescription
limitnumber (1-100)25Page size
cursorstring--Cursor for pagination
archivedOnlybooleanfalseThe archive instead of the inbox
unreadOnlybooleanfalseUnread rows only
categoryepisode | collaboration | team | booking | system--One category
prioritylow | 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:

json
{
  "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-count

Auth: requireAuth()

Response:

json
{ "success": true, "data": { "unreadCount": 5 } }

Counts unread, non-archived notifications that are due or unscheduled.

Inbox Counts

GET /api/notifications/counts

Auth: requireAuth()

Response:

json
{
	"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=collaboration

Auth: requireAuth()

Query Parameters:

ParamTypeRequiredDescription
categoryepisode | collaboration | team | bookingNoFilter by category

Response:

json
{ "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-group

Auth: requireAuth()

Body:

FieldTypeRequiredDescription
groupKeystringYes1 to 200 chars. The bundle's group_key
linkstring | nullYesThe bundle's shared destination, or explicit null

Response:

json
{ "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-group

Auth: 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:

json
{ "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-all

Auth: requireAuth()

Response:

json
{ "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/read

Auth: requireAuth()

Response: { "success": true } or 404 if not found.

Return Single To Unread

POST /api/notifications/:id/unread

Auth: 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/archive

Auth: requireAuth()

Archives and marks as read in one operation.

Response: { "success": true } or 404 if not found.

Restore Notification

POST /api/notifications/:id/unarchive

Auth: 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/:id

Auth: 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/chat

Auth: optionalAuth() -- Accepts Bearer token OR guest access token in body.

Request Body:

json
{
	"episodeId": "uuid",
	"messageId": "msg-id",
	"accessToken": "guest-token-optional",
	"activeUserIds": ["uuid", "uuid"],
	"activeGuestIds": ["uuid", "uuid"]
}
FieldTypeRequiredDescription
episodeIdUUIDYesEpisode containing the chat
messageIdstringYesMessage that triggered notification
accessTokenstring (min 10)NoGuest access token (for guest senders)
activeUserIdsUUID[]NoUsers currently viewing chat (suppress notifications)
activeGuestIdsUUID[]NoGuests 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:

json
{
	"success": true,
	"data": { "notifiedCount": 3, "dedupedCount": 1 }
}

Mark Guest Notifications Read

POST /api/notifications/guest-read

Auth: None (guest token in body).

Request Body:

json
{
	"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-subscriptions

Auth: requireAuth()

Response:

json
{
	"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-subscriptions

Auth: requireAuth()

Request Body:

json
{
	"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/:id

Auth: requireAuth()

Same behaviour as revoke by endpoint.

Push Click Tracking

Record Push Click

POST /api/notifications/push/click

Auth: None (called from service worker).

Request Body:

json
{
	"deliveryId": "uuid",
	"notificationId": "uuid"
}

Updates the delivery record status to clicked.

Response: { "success": true }

Email Tracking

Open Tracking Pixel

GET /api/notifications/email/open/:token

Auth: 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/:token

Auth: 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/:token

Auth: 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/resend

Auth: Svix signature verification via RESEND_WEBHOOK_SECRET.

Rate Limit: 300 requests/minute per IP.

Webhook Types:

Resend EventDelivery StatusTimestamp
email.sentsentsent_at
email.delivereddelivereddelivered_at
email.openedopenedopened_at
email.clickedclickedclicked_at
email.bouncedbouncedfailed_at
email.complainedfailedfailed_at
email.failedfailedfailed_at

The handler:

  1. Verifies the Svix signature
  2. Extracts email_id from the webhook payload
  3. Finds the matching delivery record by provider_message_id
  4. Updates status (never downgrades -- see status hierarchy)
  5. Sets the appropriate timestamp

Error Responses

All endpoints follow the standard error format:

json
{ "error": "Description of what failed" }
StatusMeaning
400Invalid request body or parameters
401Missing or invalid authentication
403Insufficient permissions
404Notification or subscription not found
500Internal server error

Internal documentation - Not for public distribution