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:

ParamTypeDefaultDescription
limitnumber20Items per page (max 100)
cursorstringCursor for next page (from pagination.nextCursor)
statusstringFilter by status: active, unsubscribed, bounced, complained
tagstringFilter 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:

  1. Creates (or reuses) a subscriber record for the recipient
  2. Creates a test campaign
  3. Sends the email via Mailgun
  4. 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 delivered
  • opened — Recipient opened the email
  • clicked — Recipient clicked a link
  • failed / bounced — Delivery failed, subscriber auto-marked as bounced
  • complained — 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' }
}