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