API · v1 Production: https://send.blun.ai/api/v1 OpenAPI: /api/v1/openapi.json

REST API reference.

JSON over HTTPS. Bearer-auth. Cursor pagination. POSTs are idempotent by header. EU-hosted, GDPR-by-default.

Authentication

All requests must include Authorization: Bearer <api_key>. Keys are workspace-scoped and carry one of three permission levels: read, write, or admin. Generate keys at /admin/settings#api.

Authenticated identity is reachable via GET /me:

curl https://send.blun.ai/api/v1/me \
  -H "Authorization: Bearer pk_live_8a7b3c..."
const res = await fetch("https://send.blun.ai/api/v1/me", {
  headers: { "Authorization": `Bearer ${process.env.PULSEMAIL_KEY}` }
});
const me = await res.json();
import os, requests
r = requests.get(
    "https://send.blun.ai/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['PULSEMAIL_KEY']}"},
)
me = r.json()
req, _ := http.NewRequest("GET", "https://send.blun.ai/api/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("PULSEMAIL_KEY"))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
Response 200 OK
{
  "id": "usr_3fa8e9d3",
  "workspace_id": "ws_blun_main",
  "scopes": ["read", "write", "send"],
  "key_label": "prod-server-1",
  "created_at": "2026-04-12T08:14:00Z"
}

Errors

Standard HTTP status codes are used. Failures return a JSON object with a stable error.code string (machine-readable) and an error.message (human-readable).

StatuscodeWhen
400invalid_requestMalformed body, missing required field, or schema violation.
401unauthorizedNo bearer token, expired key, or revoked key.
403forbidden_scopeKey lacks the scope needed for the operation (e.g. send on a read-only key).
404not_foundResource does not exist in this workspace.
409conflictIdempotency-Key reuse with a different body, or duplicate resource.
422unprocessableBody parses but a downstream rule rejects it (e.g. unverified sender domain).
429rate_limitedQuota exceeded. Retry-After header is set in seconds.
500server_errorGeneric upstream failure. Includes a request_id for support.
{
  "error": {
    "code": "forbidden_scope",
    "message": "This key has scope=read; send_campaign requires scope=send.",
    "request_id": "req_61a8e9d3c2"
  }
}

Rate limits

Two layers of quota:

  • 600 req/min/workspace — combined across all keys in a workspace.
  • 60 req/min/key — per individual API key.

When exceeded, the API returns 429 rate_limited with a Retry-After header (seconds). Successful responses also include three informational headers: X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix timestamp).

Sending throughput is metered separately and capped per plan; see pricing.

Idempotency

All POST requests must include an Idempotency-Key header (UUID v4 recommended). Replays of the same key return the original response — body and status — for 24 hours.

Reusing the same key with a different request body returns 409 conflict. This protects you from sending the same campaign twice when a network blip causes a retry.

curl -X POST https://send.blun.ai/api/v1/campaigns \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: 8a4d1e21-3cf2-4f7c-91ad-bba9fce1a013" \
  -H "Content-Type: application/json" \
  -d '{"list_id": "lst_8a7b3c", "subject": "Hi", "from": {...}}'

Pagination

List endpoints use cursor-based pagination. Pass ?limit= (1–200, default 50) and on subsequent calls ?cursor= using the value from the previous response. Cursors are opaque tokens — do not parse them.

FieldTypeDescription
dataarrayThe page of results.
next_cursorstring|nullPass to the next call, or null at the last page.
total_estimateintegerApproximate total count (eventual consistency).

Versioning

The current major version is v1, encoded in the path. Breaking changes will ship under v2; v1 will continue to receive security fixes for at least 12 months after a successor is announced.

Non-breaking additions (new endpoints, optional fields) ship continuously and are documented in /changelog.

Lists

A list is a named collection of subscribers. Each subscriber belongs to exactly one list — to keep the same person across multiple lists, copy the address with the import endpoint.

Object schema

FieldTypeDescription
idstringPrefixed identifier, e.g. lst_8a7b3c.
namestringHuman-readable name.
subscribersintegerActive subscriber count (excludes unsubscribed/bounced).
double_opt_inbooleanWhether new subscribers receive a confirmation email.
default_fromobject{ "email", "name" } — used when a campaign omits a from.
created_atiso-8601Creation timestamp.
GET /api/v1/lists read

List all lists in the workspace, paginated.

Query params
FieldTypeDescription
limitintegerPage size (1–200, default 50).
cursorstringPagination cursor.
curl "https://send.blun.ai/api/v1/lists?limit=50" \
  -H "Authorization: Bearer pk_live_..."
const res = await fetch("https://send.blun.ai/api/v1/lists?limit=50", {
  headers: { "Authorization": `Bearer ${process.env.PULSEMAIL_KEY}` }
});
const { data, next_cursor } = await res.json();
r = requests.get(
    "https://send.blun.ai/api/v1/lists",
    headers={"Authorization": f"Bearer {KEY}"},
    params={"limit": 50},
)
data = r.json()["data"]
req, _ := http.NewRequest("GET", "https://send.blun.ai/api/v1/lists?limit=50", nil)
req.Header.Set("Authorization", "Bearer "+key)
resp, _ := http.DefaultClient.Do(req)
var out struct{ Data []List }
json.NewDecoder(resp.Body).Decode(&out)
Response 200 OK
{
  "data": [
    { "id": "lst_8a7b3c", "name": "Newsletter", "subscribers": 12480, "double_opt_in": true },
    { "id": "lst_4f9e1d", "name": "VIP buyers", "subscribers": 312,   "double_opt_in": false }
  ],
  "next_cursor": null,
  "total_estimate": 2
}
GET /api/v1/lists/:id read

Retrieve a single list by id.

Path params
FieldTypeDescription
id*stringList identifier.
curl https://send.blun.ai/api/v1/lists/lst_8a7b3c \
  -H "Authorization: Bearer pk_live_..."
const r = await fetch("https://send.blun.ai/api/v1/lists/lst_8a7b3c", {
  headers: { "Authorization": `Bearer ${KEY}` }
});
r = requests.get(f"https://send.blun.ai/api/v1/lists/{list_id}",
  headers={"Authorization": f"Bearer {KEY}"})
req, _ := http.NewRequest("GET", baseURL+"/lists/"+id, nil)
req.Header.Set("Authorization", "Bearer "+key)
POST /api/v1/lists write

Create a new list. Requires Idempotency-Key.

Body
FieldTypeDescription
name*stringDisplay name.
double_opt_inbooleanDefault true.
default_fromobject{ email, name } — must be a verified sender.
curl -X POST https://send.blun.ai/api/v1/lists \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name": "Spring buyers", "double_opt_in": true}'
const r = await fetch("https://send.blun.ai/api/v1/lists", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ name: "Spring buyers", double_opt_in: true })
});
import uuid
r = requests.post(
  "https://send.blun.ai/api/v1/lists",
  headers={
    "Authorization": f"Bearer {KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
  },
  json={"name": "Spring buyers", "double_opt_in": True},
)
body := `{"name":"Spring buyers","double_opt_in":true}`
req, _ := http.NewRequest("POST", baseURL+"/lists", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", uuid.NewString())
req.Header.Set("Content-Type", "application/json")
PATCH /api/v1/lists/:id write

Update list metadata. Pass only the fields you want to change.

curl -X PATCH https://send.blun.ai/api/v1/lists/lst_8a7b3c \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name": "Newsletter (renamed)"}'
DELETE /api/v1/lists/:id admin

Permanently delete a list and all its subscribers. Cannot be undone.

curl -X DELETE https://send.blun.ai/api/v1/lists/lst_8a7b3c \
  -H "Authorization: Bearer pk_live_..."

Subscribers

A subscriber is an email address inside one list, plus consent metadata, custom fields, tags, and engagement history.

Object schema

FieldTypeDescription
idstringPrefixed identifier, e.g. sub_6f3e9a.
list_idstringOwning list.
emailstringValidated against RFC 5322 + DNS MX.
statusenumsubscribed | unsubscribed | bounced | pending.
fieldsobjectCustom fields keyed by name.
tagsstring[]Tag identifiers.
consent_atiso-8601When consent was recorded.
consent_sourcestringFree-text label describing where consent was collected.
last_open_atiso-8601Last time the subscriber opened a campaign.
GET /api/v1/lists/:list_id/subscribers read

List subscribers in a list, optionally filtered by status, tag, or last-open window.

Query params
FieldTypeDescription
statusenumFilter by status.
tagstringFilter by tag id.
last_open_beforeiso-8601Useful for re-engagement segments.
limitinteger1–200, default 50.
cursorstringPagination cursor.
curl "https://send.blun.ai/api/v1/lists/lst_8a7b3c/subscribers?status=subscribed&limit=100" \
  -H "Authorization: Bearer pk_live_..."
const r = await fetch(
  `https://send.blun.ai/api/v1/lists/${listId}/subscribers?status=subscribed&limit=100`,
  { headers: { "Authorization": `Bearer ${KEY}` } }
);
r = requests.get(
    f"https://send.blun.ai/api/v1/lists/{lid}/subscribers",
    headers={"Authorization": f"Bearer {KEY}"},
    params={"status": "subscribed", "limit": 100},
)
POST /api/v1/lists/:list_id/subscribers write

Add a subscriber. consent_source is required and recorded in the audit log.

Body
FieldTypeDescription
email*stringRequired.
consent_source*stringRequired. Free-text describing how consent was obtained.
fieldsobjectCustom fields.
tagsstring[]Initial tags.
curl -X POST https://send.blun.ai/api/v1/lists/lst_8a7b3c/subscribers \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "anna@example.eu",
        "fields": {"first_name": "Anna"},
        "tags": ["happy-customer"],
        "consent_source": "checkout-form"
      }'
await fetch(`https://send.blun.ai/api/v1/lists/${listId}/subscribers`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    email: "anna@example.eu",
    fields: { first_name: "Anna" },
    tags: ["happy-customer"],
    consent_source: "checkout-form"
  })
});
r = requests.post(
  f"https://send.blun.ai/api/v1/lists/{lid}/subscribers",
  headers={
    "Authorization": f"Bearer {KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
  },
  json={
    "email": "anna@example.eu",
    "fields": {"first_name": "Anna"},
    "tags": ["happy-customer"],
    "consent_source": "checkout-form",
  },
)
payload := `{
  "email":"anna@example.eu",
  "consent_source":"checkout-form",
  "tags":["happy-customer"]
}`
req, _ := http.NewRequest("POST",
  baseURL+"/lists/"+listID+"/subscribers",
  strings.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", uuid.NewString())
Response 201 Created
{
  "id": "sub_6f3e9a",
  "list_id": "lst_8a7b3c",
  "email": "anna@example.eu",
  "status": "pending",
  "consent_at": "2026-05-04T10:21:43Z",
  "tags": ["happy-customer"]
}
PATCH /api/v1/subscribers/:id write

Update fields, tags, or status on a subscriber.

curl -X PATCH https://send.blun.ai/api/v1/subscribers/sub_6f3e9a \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"fields": {"city": "Vienna"}, "tags": ["vip"]}'
DELETE /api/v1/subscribers/:id admin

Hard-delete a subscriber and their engagement history. Use the unsubscribe endpoint for soft removal.

POST /api/v1/lists/:list_id/subscribers/import write

Bulk import from CSV or JSON. Returns a job id; progress is delivered via the import.progress webhook.

curl -X POST https://send.blun.ai/api/v1/lists/lst_8a7b3c/subscribers/import \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: text/csv" \
  --data-binary @subscribers.csv

Templates

Templates are reusable, parameterised email layouts assembled from blocks. A template declares its variables; rendering happens when a campaign is composed.

Object schema

FieldTypeDescription
idstringe.g. tpl_monthly_v3.
namestringDisplay name.
categorystringe.g. newsletter, welcome, announcement.
variablesstring[]Required handlebar names.
blocksBlock[]Composition list (hero, text, image, button…).
preview_urlstringStatic rendered preview.
GET /api/v1/templates read

List templates, filterable by category.

curl "https://send.blun.ai/api/v1/templates?category=newsletter" \
  -H "Authorization: Bearer pk_live_..."
const r = await fetch("https://send.blun.ai/api/v1/templates?category=newsletter", {
  headers: { "Authorization": `Bearer ${KEY}` }
});
r = requests.get(
    "https://send.blun.ai/api/v1/templates",
    headers={"Authorization": f"Bearer {KEY}"},
    params={"category": "newsletter"},
)
GET /api/v1/templates/:id read

Retrieve one template, including its block tree.

POST /api/v1/templates write

Create a template from a block array. Returns the new tpl_… id.

curl -X POST https://send.blun.ai/api/v1/templates \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Welcome v4",
        "category": "welcome",
        "variables": ["first_name"],
        "blocks": [
          {"kind": "hero", "props": {"headline": "Welcome, {{first_name}}"}},
          {"kind": "text", "props": {"body": "We are glad you are here."}},
          {"kind": "button", "props": {"label": "Open dashboard", "href": "https://send.blun.ai/dash"}}
        ]
      }'
await fetch("https://send.blun.ai/api/v1/templates", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    name: "Welcome v4",
    category: "welcome",
    variables: ["first_name"],
    blocks: [
      { kind: "hero", props: { headline: "Welcome, {{first_name}}" } },
      { kind: "text", props: { body: "We are glad you are here." } }
    ]
  })
});
PATCH /api/v1/templates/:id write

Edit blocks, name, or category in place. Existing campaigns continue using the snapshot they were composed against.

DELETE /api/v1/templates/:id admin

Delete a template. Past campaigns retain their content snapshot.

Campaigns

A campaign is a draft envelope: subject, sender, list, and either a template id or an inline block array. Sending happens via a separate transition into a Send object.

Object schema

FieldTypeDescription
idstringe.g. cmp_61a8e9d3.
subjectstringSubject line. Personalisation tokens supported.
fromobject{ email, name } from a verified sender.
list_idstringAudience list.
segment_idstring|nullOptional narrowing segment.
template_idstring|nullSet when composed from a template.
statusenumdraft | scheduled | sending | sent | cancelled.
scheduled_atiso-8601|nullSet when scheduled.
sent_atiso-8601|nullSet when fully dispatched.
analyticsobjectLazy-loaded summary; full report at /campaigns/:id/analytics.
GET /api/v1/campaigns read

List campaigns, filterable by status and a since-window.

curl "https://send.blun.ai/api/v1/campaigns?status=sent&since=2026-04-01T00:00:00Z" \
  -H "Authorization: Bearer pk_live_..."
POST /api/v1/campaigns write

Compose a new draft campaign. Returns the campaign id; transition to send via /campaigns/:id/send.

Body
FieldTypeDescription
list_id*stringRequired (or segment_id).
subject*stringRequired. Subject line.
from*objectRequired. Verified sender object.
template_idstringEither this or blocks.
blocksBlock[]Inline composition.
variablesobjectDefault values for template handlebars.
curl -X POST https://send.blun.ai/api/v1/campaigns \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
        "list_id": "lst_8a7b3c",
        "template_id": "tpl_monthly_v3",
        "subject": "May highlights — handpicked for {{first_name}}",
        "from": {"email": "team@blun.ai", "name": "BLUN team"}
      }'
const r = await fetch("https://send.blun.ai/api/v1/campaigns", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    list_id: "lst_8a7b3c",
    template_id: "tpl_monthly_v3",
    subject: "May highlights",
    from: { email: "team@blun.ai", name: "BLUN team" }
  })
});
const { id } = await r.json();
r = requests.post(
  "https://send.blun.ai/api/v1/campaigns",
  headers={
    "Authorization": f"Bearer {KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
  },
  json={
    "list_id": "lst_8a7b3c",
    "template_id": "tpl_monthly_v3",
    "subject": "May highlights",
    "from": {"email": "team@blun.ai", "name": "BLUN team"},
  },
)
payload := `{
  "list_id":"lst_8a7b3c",
  "template_id":"tpl_monthly_v3",
  "subject":"May highlights",
  "from":{"email":"team@blun.ai","name":"BLUN team"}
}`
req, _ := http.NewRequest("POST", baseURL+"/campaigns", strings.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", uuid.NewString())
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
Response 201 Created
{
  "id": "cmp_61a8e9d3",
  "status": "draft",
  "subject": "May highlights — handpicked for {{first_name}}",
  "list_id": "lst_8a7b3c",
  "template_id": "tpl_monthly_v3",
  "created_at": "2026-05-04T10:21:43Z"
}
POST /api/v1/campaigns/:id/send send

Send a draft now, or schedule it. Pass send_at to schedule, or optimize: true to let the engine pick the best slot.

curl -X POST https://send.blun.ai/api/v1/campaigns/cmp_61a8e9d3/send \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"send_at": "2026-05-12T07:30:00Z"}'
await fetch(`https://send.blun.ai/api/v1/campaigns/${id}/send`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ optimize: true })
});
requests.post(
  f"https://send.blun.ai/api/v1/campaigns/{cid}/send",
  headers={
    "Authorization": f"Bearer {KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
  },
  json={"optimize": True},
)
Response 202 Accepted
{
  "send_id": "snd_42ab17f0",
  "status": "scheduled",
  "scheduled_at": "2026-05-12T07:30:00Z",
  "queued_recipients": 12480
}
GET /api/v1/campaigns/:id/analytics read

Full analytics: opens, clicks, geo, devices, hour-of-week heatmap. Real-time within ~10 seconds of dispatch.

curl https://send.blun.ai/api/v1/campaigns/cmp_61a8e9d3/analytics \
  -H "Authorization: Bearer pk_live_..."
DELETE /api/v1/campaigns/:id admin

Delete a draft. Sent campaigns cannot be deleted; cancel scheduled ones via /sends/:id/cancel.

Tags

Tags are workspace-scoped labels applied to subscribers. They drive segments and analytics breakdowns.

GET/api/v1/tagsList all tags in the workspace.
POST/api/v1/tagsCreate a tag.
PATCH/api/v1/tags/:idRename a tag.
DELETE/api/v1/tags/:idDelete a tag (removed from all subscribers).
POST/api/v1/subscribers/:id/tagsAttach tags to a subscriber.
DELETE/api/v1/subscribers/:id/tags/:tagDetach one tag.

Segments

Segments are saved filter expressions over subscribers — by tag, custom field, status, and engagement metrics.

GET/api/v1/segmentsList segments.
GET/api/v1/segments/:idRetrieve segment metadata + size.
GET/api/v1/segments/:id/previewPreview matching subscribers (paginated).
POST/api/v1/segmentsCreate from filter expression.
PATCH/api/v1/segments/:idUpdate filters.
DELETE/api/v1/segments/:idDelete segment.

Blocks

Reusable building units with strict per-kind schemas: hero, text, button, image, spacer, quote, divider.

GET/api/v1/blocksList saved blocks.
GET/api/v1/blocks/:idRetrieve a block including rendered HTML.
POST/api/v1/blocksBuild a block from kind + props.
PATCH/api/v1/blocks/:idEdit a block (re-renders MJML).
DELETE/api/v1/blocks/:idDelete (existing campaigns keep their snapshot).

Sends

A Send is a single dispatch job — created when a campaign transitions out of draft. Sends track per-recipient delivery state.

GET/api/v1/sendsList sends, filterable by status.
GET/api/v1/sends/:idRetrieve send progress + counts.
POST/api/v1/sends/:id/cancelCancel a scheduled or in-flight send.
GET/api/v1/sends/:id/recipientsPer-recipient state (paginated).

Webhooks

Receive signed POSTs for every lifecycle event. Each request carries an X-Pulsemail-Signature header — HMAC-SHA256 of the raw body using your endpoint secret. Verify before processing.

GET/api/v1/webhooksList configured endpoints.
GET/api/v1/webhooks/:idRetrieve one endpoint + recent deliveries.
POST/api/v1/webhooksRegister an endpoint with subscribed event types.
PATCH/api/v1/webhooks/:idUpdate URL, secret, or subscribed events.
DELETE/api/v1/webhooks/:idRemove an endpoint.
POST/api/v1/webhooks/:id/replayReplay the last 24h of deliveries.

Events

The append-only stream of every action: opens, clicks, bounces, unsubscribes, spam complaints, send progress, and consent changes.

GET/api/v1/eventsCursor-paginated event feed.
GET/api/v1/events/:idRetrieve a single event.
GET/api/v1/campaigns/:id/eventsEvents scoped to one campaign.
GET/api/v1/subscribers/:id/eventsEngagement timeline for one subscriber.

Workspaces

A workspace is the top-level isolation boundary. Each key, sender domain, and audit trail is workspace-scoped.

GET/api/v1/workspaces/currentInspect the workspace this key belongs to.
PATCH/api/v1/workspaces/currentUpdate name and default region.
GET/api/v1/workspaces/current/usageCurrent-period send and storage counters.
GET/api/v1/workspaces/current/auditAudit log (admin scope).