Newsletter Service API
The Newsletter Service is a headless microservice that manages subscribers, campaigns, and email delivery via Mailgun. It’s the first of TeamDay’s backend services — a pattern that will extend to other AI Offices (SEO, Design Studio, etc.).
Base URL: All endpoints are accessed through the Nuxt proxy at /api/services/newsletter/.
Authentication: Include an Organization API Token or Personal Access Token in the Authorization header.
curl -H "Authorization: Bearer otk_your-token-here" \
https://cc.teamday.ai/api/services/newsletter/subscribers
Architecture
Your App / Script
│
▼
Nuxt Proxy (/api/services/newsletter/*)
│ Authenticates (Firebase JWT / PAT / Org Token)
│ Resolves orgId
│ Checks scopes (otk_ tokens)
▼
Newsletter Service (H3, port 3200)
│ Row-Level Security (Postgres RLS)
▼
PostgreSQL (newsletter schema)
│
▼
Mailgun (email delivery)
Multi-tenancy: Every table has an org_id column enforced by Postgres Row-Level Security. Even if application code has a bug, data can’t leak across organizations.
Subscribers
List Subscribers
GET /api/services/newsletter/subscribers?limit=20&cursor={id}
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit | number | 20 | Items per page (max 100) |
cursor | string | — | Cursor for next page (from pagination.nextCursor) |
status | string | — | Filter by status: active, unsubscribed, bounced, complained |
tag | string | — | Filter by tag (exact match within JSON array) |
Response:
{
"data": [
{
"id": "uuid",
"orgId": "org123",
"email": "[email protected]",
"name": "Jane Doe",
"status": "active",
"tags": ["signup", "beta"],
"unsubscribeToken": "tok_abc",
"createdAt": "2026-02-24T00:00:00.000Z",
"updatedAt": "2026-02-24T00:00:00.000Z"
}
],
"pagination": {
"nextCursor": "uuid-of-last-item",
"hasMore": true
}
}
Create Subscriber
POST /api/services/newsletter/subscribers
{
"email": "[email protected]",
"name": "Jane Doe",
"tags": ["signup"]
}
Response: Returns the created subscriber object.
Note: Email addresses are stored lowercase. Duplicate emails within the same org return a 409 Conflict.
Get Subscriber
GET /api/services/newsletter/subscribers/{id}
Update Subscriber
PATCH /api/services/newsletter/subscribers/{id}
{
"name": "Jane Smith",
"tags": ["vip", "beta"],
"status": "active"
}
Delete Subscriber
DELETE /api/services/newsletter/subscribers/{id}
Import Subscribers (CSV)
POST /api/services/newsletter/subscribers/import
{
"csv": "email,name,tags\n[email protected],Jane Doe,vip;beta\n[email protected],John Smith,beta"
}
CSV format:
- Required column:
email - Optional columns:
name,tags(semicolon-separated) - First row must be headers
- Duplicates are silently skipped (ON CONFLICT DO NOTHING)
Response:
{
"imported": 2,
"total": 2
}
Campaigns
List Campaigns
GET /api/services/newsletter/campaigns?limit=20&cursor={id}
Create Campaign
POST /api/services/newsletter/campaigns
{
"name": "Weekly Update #12",
"subject": "What's new this week",
"fromName": "TeamDay",
"fromEmail": "[email protected]",
"htmlBody": "<h1>Weekly Update</h1><p>Here's what happened...</p>",
"textBody": "Weekly Update\n\nHere's what happened..."
}
Campaigns are created in draft status.
Get Campaign
GET /api/services/newsletter/campaigns/{id}
Update Campaign
PATCH /api/services/newsletter/campaigns/{id}
Only draft campaigns can be updated.
{
"subject": "Updated Subject Line",
"htmlBody": "<h1>New content</h1>"
}
Send Campaign
POST /api/services/newsletter/campaigns/{id}/send
Sends the campaign to all active subscribers. The campaign status transitions: draft -> sending -> sent (or failed).
Response:
{
"success": true,
"stats": {
"total": 150,
"sent": 150,
"failed": 0
}
}
Campaign Stats
GET /api/services/newsletter/campaigns/{id}/stats
Returns delivery statistics including sends, opens, clicks, bounces, and complaints (populated via Mailgun webhooks).
Campaign Preview
GET /api/services/newsletter/campaigns/{id}/preview
Returns the rendered HTML for previewing before send.
Test Send
Send a Test Email
POST /api/services/newsletter/test/send
{
"to": "[email protected]",
"subject": "Test Email",
"html": "<h1>Hello!</h1><p>This is a test.</p>",
"text": "Hello! This is a test."
}
This is a convenience endpoint that:
- Creates (or reuses) a subscriber record for the recipient
- Creates a test campaign
- Sends the email via Mailgun
- Records the send and marks the campaign as sent
Response:
{
"success": true,
"messageId": "<msg-id@mailgun>",
"to": "[email protected]",
"subject": "Test Email",
"unsubscribeUrl": "https://cc.teamday.ai/unsubscribe/tok_abc"
}
Public Endpoints
These endpoints don’t require authentication:
Unsubscribe Page
GET /unsubscribe/{token}
Returns a minimal HTML confirmation page. Subscribers see this when clicking the unsubscribe link in emails.
Process Unsubscribe
POST /unsubscribe/{token}
Sets the subscriber’s status to unsubscribed and records the timestamp. Idempotent — calling multiple times is safe.
Mailgun Webhooks
POST /webhooks/mailgun
Receives delivery events from Mailgun (delivered, opened, clicked, bounced, complained). Signature verified via HMAC-SHA256.
Events processed:
delivered— Email successfully deliveredopened— Recipient opened the emailclicked— Recipient clicked a linkfailed/bounced— Delivery failed, subscriber auto-marked as bouncedcomplained— Spam complaint, subscriber auto-unsubscribed
Health Check
GET /health
Returns { "status": "ok" }. No auth required.
Pagination
All list endpoints use cursor-based pagination for consistent results even when data changes between requests.
# First page
curl -H "Authorization: Bearer $TOKEN" \
"https://cc.teamday.ai/api/services/newsletter/subscribers?limit=10"
# Next page (use nextCursor from previous response)
curl -H "Authorization: Bearer $TOKEN" \
"https://cc.teamday.ai/api/services/newsletter/subscribers?limit=10&cursor=uuid-from-response"
Pagination response format:
{
"data": [...],
"pagination": {
"nextCursor": "last-item-id",
"hasMore": true
}
}
When hasMore is false, you’ve reached the last page.
Quick Start: CLI Workflow
Here’s the complete newsletter workflow using curl and a Personal Access Token. Verified end-to-end on production.
1. Test your connection
export TOKEN="td_your-token-here"
curl -s -H "Authorization: Bearer $TOKEN" \
https://cc.teamday.ai/api/services/newsletter/health
# → {"status":"ok","service":"newsletter"}
2. Import your team as subscribers
# Fetch org members and import as subscribers via CSV
ORG_ID="your-org-id"
curl -s -H "Authorization: Bearer $TOKEN" \
"https://cc.teamday.ai/api/organizations/$ORG_ID/members" \
| jq -r '"email,name,tags", (.members[] | select(.email) | "\(.email),\(.displayName // ""),team")' \
> /tmp/team.csv
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"csv\": $(jq -Rs . /tmp/team.csv)}" \
https://cc.teamday.ai/api/services/newsletter/subscribers/import
# → {"imported":3,"skipped":0,"total":3}
3. Create a campaign, set content, and preview
# Create
CAMPAIGN=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Weekly Update #1"}' \
https://cc.teamday.ai/api/services/newsletter/campaigns)
CAMPAIGN_ID=$(echo $CAMPAIGN | jq -r '.id')
# Set subject + HTML
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject":"This Week at TeamDay","htmlBody":"<h1>Hello!</h1><p>News here...</p>"}' \
"https://cc.teamday.ai/api/services/newsletter/campaigns/$CAMPAIGN_ID"
# Preview HTML
curl -s -H "Authorization: Bearer $TOKEN" \
"https://cc.teamday.ai/api/services/newsletter/campaigns/$CAMPAIGN_ID/preview"
4. Send a test email first
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"[email protected]","subject":"Test","html":"<h1>Preview</h1>"}' \
https://cc.teamday.ai/api/services/newsletter/test/send
# → {"success":true,"messageId":"<[email protected]>"}
5. Send to all subscribers
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
"https://cc.teamday.ai/api/services/newsletter/campaigns/$CAMPAIGN_ID/send"
Complete Example: Newsletter Signup Integration
Here’s how to connect a website signup form to the TeamDay Newsletter:
// 1. Generate an org token with 'newsletter' scope
// Organization Settings → Org Tokens → Generate Token
// 2. In your website's API handler:
const TEAMDAY_TOKEN = process.env.TEAMDAY_ORG_TOKEN
export async function handleSignup(email: string, name: string) {
// Add to newsletter
const res = await fetch(
'https://cc.teamday.ai/api/services/newsletter/subscribers',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${TEAMDAY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
name,
tags: ['website-signup', new Date().getFullYear().toString()],
}),
}
)
if (res.status === 409) {
// Subscriber already exists — not an error
return { status: 'already_subscribed' }
}
if (!res.ok) {
throw new Error(`Newsletter sync failed: ${res.statusText}`)
}
return { status: 'subscribed' }
}
Related Documentation
- Organization API Tokens — Generate scoped tokens for service access
- Authentication — Overview of all auth methods
- API Reference — Full API documentation