logicspike/docs

Newsletter

Newsletter Service — API Specification

Last Updated: 2026-06-28 Status: Active Base URL (local): http://localhost:8795 Base URL (production): via gateway at api.vlozi.app/newsletter/*

All admin routes require x-gateway-key + x-tenant-id + x-user-permissions from the gateway. See permissions.md for the permission matrix.


1. Subscribers

POST /subscribers

Create a single subscriber. Skips double opt-in — confirmed_at is set immediately.

Permission: newsletter:subscribers.create

Request:

{
  "email": "alice@example.com",
  "name": "Alice",
  "tags": ["vip", "early-access"],
  "metadata": { "plan": "pro", "country": "IN" },
  "source": "manual"
}

Response 201:

{ "id": "sub_...", "email": "alice@example.com", "status": "active", "confirmedAt": "2026-06-28T..." }

GET /subscribers

List subscribers with optional filters and cursor pagination.

Permission: newsletter:subscribers.read

Query params:

Param Type Notes
status string active | unsubscribed | bounced | complained
tag string Filter by tag (exact match, single tag)
cursor string Cursor for next page
limit number Default 50, max 200

Response 200:

{
  "data": [{ "id": "sub_...", "email": "...", "name": "...", "status": "active", "tags": [], "confirmedAt": "..." }],
  "meta": { "limit": 50, "nextCursor": "...", "hasMore": true }
}

GET /subscribers/stats

Count subscribers grouped by status.

Permission: newsletter:subscribers.read

Response 200:

{ "active": 1240, "pending": 32, "unsubscribed": 88, "bounced": 4, "complained": 1 }

POST /subscribers/import

Bulk import subscribers. Max 5000 per request. Deduplicates by email. All imported subscribers skip double opt-in.

Permission: newsletter:subscribers.create

Request:

{
  "subscribers": [
    { "email": "bob@example.com", "name": "Bob", "tags": ["newsletter"] }
  ],
  "overrideTags": ["imported-2026"]
}

Response 200:

{ "imported": 1, "skipped": 0, "errors": [] }

POST /subscribers/:id/unsubscribe

Mark a subscriber as unsubscribed (admin action, no token required).

Permission: newsletter:subscribers.create

Response 200: { "unsubscribed": true }


DELETE /subscribers/:id

Hard delete a subscriber. The email is pseudonymised in nl_subscriber_events but subscriber_id is set to null.

Permission: newsletter:subscribers.delete

Response 204


2. Templates

POST /templates

Create a template. variables array is auto-extracted from htmlBody.

Permission: newsletter:templates.create

Request:

{
  "name": "Monthly Digest",
  "subject": "{{ now.year }} Newsletter — {{ tenant.name }}",
  "htmlBody": "<h1>Hi {{ subscriber.name }}</h1>...",
  "textBody": "Hi {{ subscriber.name }}...",
  "previewText": "Your monthly update"
}

Response 201: Full template object including variables: ["subscriber.name", "tenant.name", "now.year"].


GET /templates

List all templates. Includes htmlBody for thumbnail rendering.

Permission: newsletter:templates.read

Response 200:

{ "data": [{ "id": "tpl_...", "name": "...", "subject": "...", "htmlBody": "...", "variables": [], "updatedAt": "..." }] }

GET /templates/:id

Get a single template.

Permission: newsletter:templates.read

Response 200: Full template object.


PUT /templates/:id

Update template. Re-extracts variables from htmlBody.

Permission: newsletter:templates.create


POST /templates/:id/duplicate

Clone a template. Returns new template with name appended " (copy)".

Permission: newsletter:templates.create

Response 201: New template object.


DELETE /templates/:id

Delete template. Does not affect campaigns that were based on it (snapshot semantics).

Permission: newsletter:templates.delete

Response 204


POST /templates/:id/test-send

Send the template to a single email address. Costs 1 credit. Uses the same render context as a campaign send.

Permission: newsletter:campaigns.send

Request:

{ "to": "dev@example.com" }

Response 200: { "sent": true, "creditsCharged": 1 }

Errors: 402 insufficient credits.


3. Campaigns

POST /campaigns

Create a draft campaign. Optionally based on a template (snapshots body at create time).

Permission: newsletter:campaigns.create

Request:

{
  "name": "June Newsletter",
  "subject": "Big news from {{ tenant.name }}",
  "htmlBody": "<h1>...</h1>",
  "textBody": "...",
  "previewText": "...",
  "templateId": "tpl_...",
  "segmentId": "seg_...",
  "scheduledAt": "2026-07-01T09:00:00Z"
}

If scheduledAt is set, status is set to scheduled and a CampaignScheduler DO alarm is registered.

Response 201: Full campaign object.


GET /campaigns

List campaigns, newest first. Cursor-paginated.

Permission: newsletter:campaigns.read

Query params: status, cursor, limit (default 20)


GET /campaigns/:id

Get campaign with stats.

Permission: newsletter:campaigns.read

Response 200:

{
  "id": "camp_...",
  "name": "June Newsletter",
  "status": "sent",
  "totalRecipients": 1240,
  "totalSent": 1238,
  "totalDelivered": 1200,
  "totalOpened": 342,
  "totalClicked": 88,
  "openRate": 0.285,
  "clickRate": 0.073,
  "sentAt": "2026-06-28T10:00:00Z"
}

PUT /campaigns/:id

Update a campaign. Body fields (subject, htmlBody, etc.) are locked once status=scheduled.

Permission: newsletter:campaigns.create


POST /campaigns/:id/send

Fire a campaign immediately. Charges credits, enqueues per-subscriber jobs.

Permission: newsletter:campaigns.send

Response 200:

{ "queued": 1240, "creditsCharged": 248, "txId": "tx_..." }

Errors:

  • 402 — insufficient credits; body includes neededCredits
  • 409 — campaign already sending or sent

POST /campaigns/:id/cancel

Cancel a scheduled campaign. Deletes the DO alarm.

Permission: newsletter:campaigns.create

Response 200: { "cancelled": true }


GET /campaigns/:id/sends

List per-subscriber send records for a campaign. Cursor-paginated.

Permission: newsletter:campaigns.read

Query params: status, cursor, limit (default 50)

Response 200:

{
  "data": [{
    "id": "send_...",
    "subscriberEmail": "alice@example.com",
    "status": "delivered",
    "openedAt": "2026-06-28T10:05:00Z",
    "clickedAt": null,
    "error": null
  }],
  "meta": { "nextCursor": "...", "hasMore": false }
}

4. Segments

POST /segments

Create a segment with filter rules.

Permission: newsletter:subscribers.create

Request:

{
  "name": "VIP subscribers",
  "description": "Subscribers tagged vip, subscribed after Jan 2026",
  "filterRules": {
    "tags": ["vip"],
    "status": "active",
    "subscribedAfter": "2026-01-01"
  }
}

Response 201: Segment object including subscriberCount (computed at save).


GET /segments/sendable-count

Count sendable subscribers for a segment (live query, not cached).

Permission: newsletter:subscribers.read

Query: ?segmentId=seg_... (optional; omit for all confirmed+active)

Response 200: { "count": 1150 }


POST /segments/:id/preview

Preview matching subscribers for a segment.

Permission: newsletter:subscribers.read

WARNING

This endpoint is registered but not yet implemented. Returns an error. See backlog.md B1.


5. Sections (Phase D Visual Editor)

POST /sections · GET /sections · GET /sections/:id · PUT /sections/:id · DELETE /sections/:id

Standard CRUD for reusable block fragments. Max 100 sections per tenant (list cap). Permissions mirror template permissions.


6. Blog Binding

GET /blog-binding

Get binding config. Returns 404 if not configured.

Permission: newsletter:campaigns.read


PUT /blog-binding

Create or update binding. Validates that defaultTemplateId and defaultSegmentId (if set) exist for this tenant.

Permission: newsletter:campaigns.create

Request:

{
  "autoSendToggle": true,
  "defaultTemplateId": "tpl_...",
  "defaultSegmentId": "seg_...",
  "defaultFromName": "The Vlozi Team",
  "subjectTemplate": "New post: {{ title }}"
}

GET /blog-binding/context

Returns binding + all segments + all templates + live sendable count. Used by the blog publish modal to populate dropdowns.

Permission: newsletter:campaigns.read

Response 200:

{
  "binding": { "autoSendToggle": true, "defaultTemplateId": "tpl_...", ... },
  "templates": [{ "id": "tpl_...", "name": "..." }],
  "segments": [{ "id": "seg_...", "name": "...", "subscriberCount": 1240 }],
  "sendableCount": 1150
}

7. Public Routes (No Auth)

POST /public/subscribe

Form subscribe. Creates subscriber with confirmedAt = null, sends confirmation email.

Auth: x-tenant-id header (set by gateway from form API key). Rate-limited: 10 req/60s per (tenant, IP).

Request:

{ "email": "alice@example.com", "name": "Alice" }

Response 200: { "pending": true, "message": "Check your email to confirm" }

Errors:

  • 429 — rate limited
  • 409 — already subscribed (active)

POST /public/confirm

Confirm subscription via HMAC token from email link.

Auth: Query params id, t, ts, token (HMAC-signed).

Token max age: 7 days.

Response 200: { "confirmed": true }

Errors:

  • 400 — invalid or expired token
  • 404 — subscriber not found

POST /public/unsubscribe

Unsubscribe via HMAC token from email link.

Auth: Query params id, t, ts, token.

Token max age: 1 year.

Response 200: { "unsubscribed": true }


8. Internal Routes (x-internal-key Auth)

POST /internal/bounce

Mark an email address as bounced or complained. Cross-tenant — marks ALL active subscribers with this email.

Request:

{ "email": "alice@example.com", "eventType": "bounced", "reason": "5.1.1 User unknown", "messageId": "resend_..." }

Adds to nl_suppression_list. Sets status = 'bounced' or 'complained' on all matching subscriber rows.


POST /internal/event

Record engagement event for a specific send.

Request:

{ "sendId": "send_...", "type": "opened", "occurredAt": "2026-06-28T10:05:00Z" }

Types: delivered | opened | clicked | bounced | complained

Updates nl_campaign_sends and nl_campaigns aggregate counters.


POST /internal/blog/post-published

Create and fire a newsletter campaign for a blog post (if binding is configured and autoSendToggle = true).

Request:

{
  "tenantId": "ten_...",
  "postId": "post_...",
  "slug": "my-post",
  "title": "Big News",
  "excerpt": "A short summary...",
  "coverUrl": "https://...",
  "authorName": "Dipanshu",
  "publishedAt": "2026-06-28T10:00:00Z",
  "overrides": {
    "segmentId": "seg_...",
    "templateId": "tpl_...",
    "subject": "Custom subject",
    "fromName": "My Blog"
  }
}

Response 204: No binding configured (silent). Response 200: { "campaignId": "camp_...", "queued": 1240 }


POST /internal/blog/post-unpublished

Cancel a draft/scheduled blog campaign for a post.

Request: { "tenantId": "...", "postId": "..." }

Response 204: No campaign found (silent). Response 200: { "cancelled": true }


9. Health

Route Response
GET /health 200 { "ok": true, "service": "newsletter-service" }

10. Error Codes

Status Code Meaning
400 Validation error; body has { error, details }
401 unauthorized Missing or invalid gateway key
402 insufficient_credits Not enough credits; body includes neededCredits
403 forbidden Missing required permission
404 not_found Resource doesn't exist for this tenant
409 conflict State conflict (already subscribed, already sending, etc.)
413 body_too_large Campaign HTML > 110 KB
429 rate_limited Public subscribe rate limit exceeded
500 billing_misconfigured CORE_DATABASE_URL missing or unreachable
500 internal_error Unexpected server error
Newsletter