Last Updated: 2026-06-28 Status: Active Base URL (local):
http://localhost:8795Base URL (production): via gateway atapi.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.
bounceRateis bounced ÷ sent (a bounced message is never delivered, so it cannot be a share of delivered).deliveryRateis delivered ÷ sent, counting only campaigns the provider has reported any outcome for (a delivery or a bounce). It isnullwhen 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.
campaignsholds the 100 most recent;totalscovers 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 includesneededCredits409— campaign alreadysendingorsent
POST /campaigns/:id/cancel
Cancel a scheduled campaign. Deletes the DO alarm.
Permission: newsletter:campaigns.create
Response 200: { "cancelled": true }
GET /campaigns/:id/links
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 limited409— 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 token404— 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 |