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/stats

Totals and a per-campaign table over the campaigns that sent mail in a window. This powers the Deliverability page: it covers newsletter campaigns only, not the other mail a workspace sends. A campaign is dated by sent_at, or, for one that was cancelled or paused part-way or is still sending (it has no sent_at), by when its current fire started. Drafts never count.

Permission: newsletter:campaigns.read

Query params: days — 7, 30 (default) or 90

Response 200:

{
  "windowDays": 30,
  "since": "2026-08-29T10:00:00.000Z",
  "totals": {
    "campaigns": 4, "recipients": 5200, "sent": 5100, "delivered": 4980, "opened": 2100, "clicked": 610,
    "bounced": 90, "complained": 3, "unsubscribed": 41, "failed": 60, "skipped": 40,
    "deliveryRate": 0.976, "openRate": 0.422, "clickRate": 0.122,
    "bounceRate": 0.018, "complaintRate": 0.0006, "unsubscribeRate": 0.008
  },
  "campaigns": [{ "id": "camp_...", "name": "June Newsletter", "subject": "...", "sentAt": "...", "totalSent": 1238, "totalDelivered": 1200, "deliveryRate": 0.969, "openRate": 0.285, "clickRate": 0.073 }]
}

Rates are derived, never stored, and never above 1.

  • bounceRate is bounced ÷ sent (a bounced message is never delivered, so it cannot be a share of delivered).
  • deliveryRate is delivered ÷ sent, counting only campaigns the provider has reported any outcome for (a delivery or a bounce). It is null when none has, so a campaign that predates delivery tracking does not read as "0% delivered". The same applies per campaign.
  • Opens, clicks, complaints and unsubscribes are over delivered mail, falling back to sent for a campaign with no delivery confirmations yet. Totals sum the counts first and then divide, so a large campaign outweighs a small one. campaigns holds the 100 most recent; totals covers all of them. Opens and clicks are unique (see /links).

GET /campaigns/:id

Get a campaign with its counters and derived rates.

Permission: newsletter:campaigns.read

Response 200:

{
  "campaign": {
    "id": "camp_...",
    "name": "June Newsletter",
    "status": "sent",
    "totalRecipients": 1240,
    "totalSent": 1238,
    "totalDelivered": 1200,
    "totalOpened": 342,
    "totalClicked": 88,
    "totalBounced": 12,
    "totalComplained": 1,
    "totalUnsubscribed": 9,
    "deliveryRate": 0.969,
    "openRate": 0.285,
    "clickRate": 0.073,
    "bounceRate": 0.0097,
    "complaintRate": 0.0008,
    "unsubscribeRate": 0.0075,
    "tracking": { "opens": true, "clicks": true },
    "compliance": { "appended": { "html": false, "text": false } },
    "sentAt": "2026-06-28T10:00:00Z"
  }
}

tracking and compliance come from the latest fire's frozen snapshot: whether open and click tracking were on for that send, and whether a footer (unsubscribe link and postal address) was added automatically. Both are null for a campaign that predates them. The report page shows "Tracking off" instead of 0 % when tracking says the pixel or the link rewrite never ran.


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 }


The links tracked in a campaign, with unique clicks per link. Empty when the fire did not track clicks.

Permission: newsletter:campaigns.read

Response 200:

{
  "links": [{ "hash": "3f2a9c...", "url": "https://example.com/pricing", "uniqueClicks": 61, "clickRate": 0.051 }],
  "uniqueClickers": 88
}

Ordered by unique clicks. clickRate is over delivered mail. uniqueClickers is the number of people who clicked at least one link, so it can be lower than the sum of uniqueClicks. 404 for an unknown or another workspace's campaign.


GET /campaigns/:id/sends

List per-subscriber send records for a campaign. Cursor-paginated (keyset on creation time).

Permission: newsletter:campaigns.read

Query params: status (queued, dispatching, sent, delivered, bounced, skipped, failed, dead_letter, stalled), cursor, limit (1–500, default 100)

Response 200:

{
  "sends": [{
    "id": "snd_...",
    "subscriberId": "sub_...",
    "email": "alice@example.com",
    "name": "Alice",
    "status": "delivered",
    "sentAt": "2026-06-28T10:00:02Z",
    "deliveredAt": "2026-06-28T10:00:05Z",
    "openedAt": "2026-06-28T10:05:00Z",
    "clickedAt": null,
    "bouncedAt": null,
    "unsubscribedAt": null,
    "complainedAt": null,
    "error": null,
    "createdAt": "2026-06-28T09:59:58Z"
  }],
  "nextCursor": "..."
}

email and name are null once the subscriber has been deleted. nextCursor is null on the last page. The dashboard's CSV export walks this endpoint at limit=500.


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