Newsletter API
Read and manage the subscriber list, and fetch the rendered newsletter, over authenticated REST.
The Newsletter API lets a connected platform work with the SeniorLiving.News mailing list directly: list subscribers, add and remove them, and fetch the issue that has been built for a given day. It is a pull API — you call us, on your own schedule, and you do not need to host any endpoint of your own.
It is independent of the Connector API, which pushes events out to a platform that implements a receiver. Either or both may be in use. This one needs only an API key.
Who sends email
SeniorLiving.News sends no email to a reader — not the newsletter, not the double opt-in confirmation, not a reply to a contact-us message. The connected platform executes every send, and this API is where the instructions for those sends travel:
| Parameter | Type | Description |
|---|---|---|
| The newsletter | issue | GET /issues/latest returns the rendered edition to send. |
| Opt-in confirmation | contact | Signups are pushed with confirm: true, so the platform mails its own signed confirmation link and records consent on the click. confirm_url here is the same signup's redirect target and a fallback link. |
| Contact-us replies | message | GET /contact-messages returns what visitors wrote, with the address to reply to. |
| Unsubscribe receipt | none | Nothing to send: the opt-out page confirms on screen, and the same event reaches the platform as a suppression. |
Authentication
Every request carries an API key as a bearer token. Keys are issued from the site's admin area and shown exactly once at creation: only a SHA-256 fingerprint is stored, so a lost key cannot be recovered and must be replaced. A key can be revoked at any time, which takes effect immediately.
curl https://seniorliving.news/api/newsletter/v1/status \ -H "Authorization: Bearer YOUR_API_KEY"
A missing, malformed, unknown, or revoked key returns 401. This differs from the Connector API's export endpoints, which answer 404 so as not to confirm that an endpoint exists. These endpoints are publicly documented, so there is nothing to conceal, and a clear 401 saves you from debugging a silent 404.
Conventions
All requests and responses are JSON. Timestamps are ISO-8601 in UTC. Email addresses are normalised to lowercase, so Pat@Example.com and pat@example.com are the same subscriber. An email in a URL path must be percent-encoded.
Errors
Errors carry a stable machine-readable code and a human message. Branch on the code, not the message — the wording may change.
{ "error": "consent_required",
"message": "consent requires both `source` and `collected_at`, or neither." }| Parameter | Type | Description |
|---|---|---|
| unauthorized | 401 | Missing, unknown, or revoked API key. |
| invalid_request | 400 | The body or a query parameter failed validation. The message names the field. |
| consent_required | 400 | A consent claim was partial, unparseable, or dated in the future. |
| not_found | 404 | No subscriber or issue matched. |
| db_unavailable | 503 | Temporary — the request is safe to retry. |
Pagination
List endpoints are cursor-paginated. Pass next_cursor from one response as ?cursor= on the next request; a null next_cursor means you have reached the end. Cursors are keyed on creation order rather than an offset, so walking the list never repeats or skips a subscriber even while signups are arriving. limit defaults to 100 and caps at 500.
The contact object
Every subscriber endpoint returns this shape. It is byte-identical to the contact payload the Connector API pushes, so the two integrations never disagree about what a subscriber looks like.
{
"email": "pat@example.com",
"first_name": "Pat",
"last_name": "Rivera",
"zip": "19103",
"prefs": {
"email": { "briefings": "daily", "puzzles": "daily",
"onThisDay": "weekly", "local": "off" },
"sms": { "enabled": false }
},
"status": "confirmed",
"age_range": "65_74",
"gender": null,
"gender_self": null,
"categories": ["health", "travel"],
"profile_completed_at": "2026-08-03T15:12:09.000Z",
"created_at": "2026-08-01T14:03:22.512Z",
"confirmed_at": "2026-08-01T14:05:10.104Z",
"unsubscribed_at": null,
"unsubscribe_url": "https://seniorliving.news/unsubscribe?token=…",
"profile_url": "https://seniorliving.news/subscribe/profile?token=…"
}| Parameter | Type | Description |
|---|---|---|
| status | enum | pending · confirmed · unsubscribed · bounced. Only confirmed subscribers are ever sent the newsletter. |
| first_name | string | null | Null on most new contacts: signup asks only for email, ZIP and frequency. A name is collected later, optionally. |
| last_name | string | null | Null unless supplied on the post-confirmation profile page. |
| zip | string | null | 5 digits. Drives local news selection. |
| prefs.email.* | enum | Per-section cadence: off · daily · weekly. |
| categories | string[] | Any of: health, benefits, money, housing, caregiving, technology, travel, food. Empty means all topics. |
| confirm_url | string | Present only while confirmation is outstanding. The connector push already carries it as the double opt-in redirect target (confirm: true), so a platform-sent confirmation click both records consent and finalizes this record. It never expires or rotates: a re-signup keeps the same link. |
| unsubscribe_url | string | Always present. Must appear in any email you send on our behalf. |
| phone | string | Present only when the subscriber has consented to SMS. Absent is not consent. |
These URLs contain tokens. Anyone holding one can unsubscribe or edit that subscriber's profile, so treat a response from this API with the same care as the key itself. They are included because a platform sending on our behalf needs them.
Subscribers
GET https://seniorliving.news/api/newsletter/v1/status
Checks a key without touching the database. Use it to verify setup.
{ "ok": true, "version": "v1", "key": "360REV production" }GET https://seniorliving.news/api/newsletter/v1/subscribers
| Parameter | Type | Description |
|---|---|---|
| status | enum, optional | Filter to pending, confirmed, unsubscribed, or bounced. Omit for all. |
| cursor | string, optional | next_cursor from the previous page. |
| limit | int, optional | 1-500, default 100. |
curl "https://seniorliving.news/api/newsletter/v1/subscribers?status=confirmed&limit=100" \
-H "Authorization: Bearer YOUR_API_KEY"
{ "data": [ { … contact … }, … ], "next_cursor": "MjAyNi0wOC0wNVQx…" }GET https://seniorliving.news/api/newsletter/v1/subscribers/{email}
Returns one contact, or 404.
POST https://seniorliving.news/api/newsletter/v1/subscribers
Adds a subscriber. Returns 201 for a new address and 200 for one already on the list — re-adding someone never erases profile data they have already given us.
curl -X POST https://seniorliving.news/api/newsletter/v1/subscribers \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "pat@example.com", "zip": "19103" }'| Parameter | Type | Description |
|---|---|---|
| string, required | Normalised to lowercase. | |
| first_name | string, optional | |
| last_name | string, optional | |
| zip | string, optional | 5 digits, or omit. |
| phone | string, optional | Storing a number does not enable SMS; that needs separate consent. |
| prefs | object, optional | Partial. Anything omitted keeps its default. |
| categories | string[], optional | Unrecognised slugs are ignored rather than failing the request. |
| consent | object, optional | See below. Required to create an already-confirmed subscriber. |
Confirmed status and consent
By default a new subscriber is created as pending, and the response carries confirm_url. When the subscriber reaches that link — normally as the redirect target of the platform's own confirmation email, or sent directly — they become confirmed and start receiving the newsletter. This double opt-in is a legal requirement, not a preference, so there is no parameter that skips it.
If the subscriber already consented somewhere else — your own signup form, for instance — you may import them as confirmed by documenting where and when that consent was collected:
{ "email": "pat@example.com",
"consent": { "source": "360REV signup form",
"collected_at": "2026-07-14T10:02:00Z" } }Both fields are required together. Supplying one without the other returns consent_required rather than quietly creating a pending subscriber, because a half-specified consent record looks documented and is not. A collected_at in the future, or one that cannot be parsed, is rejected for the same reason. The provenance is stored against the subscriber.
PATCH https://seniorliving.news/api/newsletter/v1/subscribers/{email}
Updates a subscriber. Omitting a field leaves it alone; sending null clears it. Those are different instructions, so send only what you intend to change.
# Change frequency to weekly, leaving everything else untouched
curl -X PATCH https://seniorliving.news/api/newsletter/v1/subscribers/pat%40example.com \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "prefs": { "email": { "briefings": "weekly" } } }'
# Unsubscribe
-d '{ "status": "unsubscribed" }'prefs is merged, not replaced: setting one cadence leaves the others as they were.
status accepts only "unsubscribed". A subscriber reaches confirmed by clicking their own confirmation link or through a documented consent record at creation — never by being set. Any other value returns invalid_request.
DELETE https://seniorliving.news/api/newsletter/v1/subscribers/{email}
Permanently erases the subscriber. This is not the same as unsubscribing, and the difference matters:
| Parameter | Type | Description |
|---|---|---|
| PATCH status=unsubscribed | suppression | The record stays, marked unsubscribed with a timestamp. They will not be emailed, and re-importing the address will not resurrect them silently. Use this when someone opts out. |
| DELETE | erasure | The row is gone and cannot be recovered. Use this for a data-deletion request. Nothing then stops the same address being added again as a new subscriber. |
{ "deleted": true, "email": "pat@example.com", "previous_status": "confirmed" }Deleting also discards any queued outbound events carrying that address, so a pending sync cannot re-create the person after erasure.
Issues
An issue is recorded each time a digest runs. Both endpoints serve stored issues, so they answer immediately — nothing is rebuilt on demand.
GET https://seniorliving.news/api/newsletter/v1/issues/latest
The most recent issue, including its rendered HTML. This is what you fetch to send today's newsletter.
{
"id": "0f1e…", "kind": "daily", "cadence": "daily",
"date": "2026-08-05",
"subject": "Wednesday, August 5, 2026: …",
"site_url": "https://seniorliving.news",
"postal_address": "…",
"sections": {
"briefing": { "date": "2026-08-05", "title": "…", "url": "…" },
"on_this_day": [ { "year": 1969, "text": "…", "url": "…" } ],
"puzzle_hint": "Today's puzzle: Sudoku",
"topics": [ { "slug": "health", "label": "Health",
"summary": "…", "items": [ … ] } ]
},
"html": "<!doctype html>…"
}The HTML contains the literal placeholder {{unsubscribe_url}}. Replace it per recipient with that contact's unsubscribe_url before sending. Mailing the placeholder unmodified sends bulk email with no working unsubscribe link, which is a legal problem, not a cosmetic one.
The issue carries no personalisation: no name, and no per-ZIP local news, because one body serves every recipient. Use sections if you would rather compose your own template — segmentation is yours to do, since you hold each contact's preferences.
404 means no issue has been built yet. One is recorded on every digest run.
GET https://seniorliving.news/api/newsletter/v1/issues
Issue history, newest first. The rendered HTML is omitted unless you pass ?include=html, since it is by far the largest field.
| Parameter | Type | Description |
|---|---|---|
| limit | int, optional | 1-500, default 100. |
| include | string, optional | Set to `html` to include each issue's rendered body. |
When the site is sending its own email rather than handing off, an issue reflects the topics that run actually needed, which may be fewer than the full set.
Contact messages
What visitors write through the site's contact form. The site never replies by email itself — each message is also announced on the push side as a contact upsert carrying fields.contactMessage, but that upsert is fill-blanks-only, so a second message from the same address may not surface on the contact record. This endpoint is the lossless log: every message is its own row, in arrival order.
GET https://seniorliving.news/api/newsletter/v1/contact-messages
| Parameter | Type | Description |
|---|---|---|
| cursor | string, optional | next_cursor from the previous page. |
| limit | int, optional | 1-500, default 100. |
curl "https://seniorliving.news/api/newsletter/v1/contact-messages" \
-H "Authorization: Bearer YOUR_API_KEY"
{ "data": [ {
"id": "9b2e…",
"email": "visitor@example.com",
"name": "Grace Hopper",
"message": "How do I submit a community event?",
"submitted_at": "2026-08-12T10:30:00.000Z"
} ],
"next_cursor": null }name is null when the visitor left it blank. Writing a message is not a newsletter opt-in: contact-form senders are never asserted as consented, and they carry no subscriber status, so an audience filtered on either can't include them.
Rate limits and retries
There is no request quota. Please stay considerate: poll /issues/latest once per send rather than continuously, and walk /subscribers with the cursor instead of re-reading the list. A 503 is temporary and safe to retry with backoff; a 4xx will not succeed on retry without a change to the request.
Data & privacy
This API exposes personal data belonging to real subscribers, along with tokens that act on their behalf. Every write is recorded with the action, the address, and the outcome; deletion records are retained deliberately, so that an erasure can be evidenced. If you send email using this data you inherit the obligations that come with it — honouring unsubscribes promptly, including the postal address in the footer, and never mailing a subscriber whose status is anything other than confirmed.