Happy House - Ecommerce Docs
Developer ResourcesNewsletter

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.

QueryNotes
page, size, paginationStandard. size caps at 100
statusOptional: 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".

StatusCodeMeaning
409NEWSLETTER_CAMPAIGN_NOT_DRAFTAlready sending or sent. Two concurrent clicks: one wins, one gets this
409NEWSLETTER_CAMPAIGN_NO_RECIPIENTSNobody has confirmed. Disable Send when the confirmed count is zero
404NEWSLETTER_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

errorCodeHTTPMeaningSuggested consumer behaviour
NEWSLETTER_CONFIRMATION_TOKEN_INVALID404Used, expired, or the address unsubscribed"This link is no longer valid" page
NEWSLETTER_CAMPAIGN_NOT_FOUND404Refresh the list
NEWSLETTER_CAMPAIGN_NOT_DRAFT409Not editable or sendableRefresh the campaign; disable Edit and Send
NEWSLETTER_CAMPAIGN_NO_RECIPIENTS409Zero confirmed subscribersDisable 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.

See Also