Newsletter API
Eight endpoints — three public, five admin — with request and response shapes and every error code.
Audience: Frontend and admin developers Scope: The HTTP contract. Behaviour is on the features page.
Newsletter - API
Base URL /api. The three customer routes are also served under /api/mobile. There is no version
segment. Every response is wrapped in { message, data }.
Storefront
POST /api/newsletter/subscribe
Public. Rate limited to 3 per hour, keyed on ip+device — every accepted request can cost a real email send.
{ "email": "someone@example.com", "source": "storefront_footer" }source is optional and defaults to storefront_footer. Accepted values: storefront_footer,
checkout, admin_import, account_signup.
200
{
"message": "If that address needs confirming, we have sent it a link.",
"data": null
}The response is identical whether the address was new, already subscribed, or previously unsubscribed. Anything else turns a public form into an oracle for "is this person on this shop's list", and the person typing is frequently not the person whose address it is.
Do not build UI that claims to know which branch happened.
GET /api/newsletter/confirm?token=…
Public. Consumes the token.
200 — { "message": "Subscription confirmed.", "data": null }
404 NEWSLETTER_CONFIRMATION_TOKEN_INVALID — the link was already used, was never valid, or the
address has since unsubscribed. Render "this link is no longer valid"; do not retry.
GET /api/newsletter/unsubscribe?token=…
Public, no authentication, idempotent.
200 — { "message": "You have been unsubscribed.", "data": null }
An unknown token returns the same 200. Scanners, previewers and prefetchers follow one-click links, so an error page would be useless to the recipient and a probe oracle for everyone else.
Admin
All five require a bearer token and the named permission. Run
pnpm --filter @happy-shop/api permissions:sync before an admin build expects Newsletter_* to
exist as rows.
GET /api/admin/newsletter/campaigns
Newsletter_READ. Paginated.
| Query | Notes |
|---|---|
page, size, pagination | Standard. size caps at 100 |
status | Optional: draft | sending | sent | failed | cancelled |
{
"message": "Campaigns fetched successfully",
"data": [
{
"id": "0198f2c1-…",
"name": "Autumn sale announcement",
"subject": "Up to 40% off, this week only",
"status": "sent",
"recipientCount": 4820,
"sentCount": 4801,
"failedCount": 19,
"sentAt": "2026-08-11T09:14:22.031Z",
"createdAt": "2026-08-10T16:02:11.884Z",
"updatedAt": "2026-08-11T09:31:07.220Z"
}
],
"count": 37,
"currentPage": 1,
"totalPage": 2
}bodyMarkdown is deliberately absent from list rows. It is a text column and no list screen
renders it; read it from the detail endpoint.
GET /api/admin/newsletter/campaigns/{publicId}
Newsletter_READ. Same shape plus bodyMarkdown.
404 NEWSLETTER_CAMPAIGN_NOT_FOUND.
POST /api/admin/newsletter/campaigns
Newsletter_CREATE. Idempotent via the standard Idempotency-Key header.
{
"name": "Autumn sale announcement",
"subject": "Up to 40% off, this week only",
"bodyMarkdown": "## Autumn sale\n\nEverything reduced until Sunday."
}name is internal and never rendered to a recipient. 201 returns the created campaign in
draft.
Only two things in bodyMarkdown are interpreted: blank-line-separated blocks become paragraphs,
and a block starting ## becomes a subheading. Everything else — **bold**, [link](url), raw
HTML — renders as literal text. The composer UI should say so.
PATCH /api/admin/newsletter/campaigns/{publicId}
Newsletter_UPDATE. Any subset of name, subject, bodyMarkdown.
409 NEWSLETTER_CAMPAIGN_NOT_DRAFT — editing mid-send would change the copy for the recipients
not yet reached, so half the list receives a different email from the other half.
POST /api/admin/newsletter/campaigns/{publicId}/send
Newsletter_UPDATE. No body.
Counts the confirmed subscribers, freezes that number as recipientCount, flips the campaign to
sending, and records the outbox event — all in one transaction. Returns the campaign with
status: "sending".
| Status | Code | Meaning |
|---|---|---|
| 409 | NEWSLETTER_CAMPAIGN_NOT_DRAFT | Already sending or sent. Two concurrent clicks: one wins, one gets this |
| 409 | NEWSLETTER_CAMPAIGN_NO_RECIPIENTS | Nobody has confirmed. Disable Send when the confirmed count is zero |
| 404 | NEWSLETTER_CAMPAIGN_NOT_FOUND |
Sending is asynchronous. Poll the detail endpoint while status is sending; sentCount and
failedCount fill in as the worker runs, and the campaign closes as sent (at least one delivered)
or failed (none delivered, some attempted).
Error codes
| errorCode | HTTP | Meaning | Suggested consumer behaviour |
|---|---|---|---|
NEWSLETTER_CONFIRMATION_TOKEN_INVALID | 404 | Used, expired, or the address unsubscribed | "This link is no longer valid" page |
NEWSLETTER_CAMPAIGN_NOT_FOUND | 404 | Refresh the list | |
NEWSLETTER_CAMPAIGN_NOT_DRAFT | 409 | Not editable or sendable | Refresh the campaign; disable Edit and Send |
NEWSLETTER_CAMPAIGN_NO_RECIPIENTS | 409 | Zero confirmed subscribers | Disable Send; show the confirmed count |
NEWSLETTER_UNSUBSCRIBE_TOKEN_INVALID, NEWSLETTER_SUBSCRIBER_NOT_FOUND and
NEWSLETTER_ALREADY_CONFIRMED are registered but deliberately never thrown — every one of them
would answer a question the public surface refuses to answer.