Overview
The BLUN-Mailing API is a REST API over JSON. Send to the EU edge endpoint at api.blun.ai/v1 — your data lands in EU-hosted Nuremberg or Falkenstein, never leaves the EU. Authentication is Bearer token, idempotency via Idempotency-Key header, errors return RFC 9457 problem-details JSON.
X-Region: eu-de so your audit trail can prove residency. To explicitly select FAL only, send X-Region: eu-de-fal.Authentication
Create an API key at /admin/settings/api-keys. Keys are scoped to a workspace and can be limited to read-only or per-resource (e.g. transactional-send only). Live keys are prefixed pulse_live_; test keys are pulse_test_.
# export PULSE_KEY=pulse_live_… curl https://api.blun.ai/v1/me \ -H "Authorization: Bearer $PULSE_KEY" # → {"workspace":"BLUN HQ","scope":"full","region":"eu-de"}
Errors & idempotency
All errors follow RFC 9457 problem-details JSON: type, title, status, detail, instance. Idempotency is opt-in by sending an Idempotency-Key header on POST — the same key replayed within 24 h returns the cached first response.
curl -X POST https://api.blun.ai/v1/transactional/send \ -H "Authorization: Bearer $PULSE_KEY" \ -H "Idempotency-Key: order_84f3_confirm_2026-05-04" \ -H "Content-Type: application/json" \ -d '{ … }' # Replaying same Idempotency-Key returns the cached msg_id.
HTTP status codes
| Status | Meaning | You should |
|---|---|---|
| 200 | OK | Continue. |
| 201 | Created | Resource created. Use returned id. |
| 202 | Accepted | Queued for async send. Listen for webhook. |
| 400 | Validation error | Inspect detail; fix request. |
| 401 | Auth | Check key + scope. |
| 409 | Conflict | Idempotency-Key collision with different body. |
| 422 | Unprocessable | DKIM/SPF/DMARC misalignment, suppressed recipient, etc. |
| 429 | Rate limited | Honor Retry-After; back off. |
| 5xx | Server | Retry with same Idempotency-Key. |
Rate limits
Per-workspace rate limit is 10 requests/second sustained, burst 60. Per-domain send limit follows your warmup curve. X-RateLimit-Remaining is included on every response.
Regions & residency
Default region is eu-de (EU-hosted Nuremberg primary, Falkenstein replica). Workspace owners on Pro+ can pin to eu-de-nue or eu-de-fal only. AI inference always rotates through both DE-NUE and DE-FAL — never outside the EU. See /security#data.
POST /v1/transactional/send
Send a single email. Returns immediately with a msg_id; the actual SMTP handoff happens asynchronously and webhooks are delivered for delivered, bounced, opened, clicked.
Body parameters
| Field | Type | Req? | Description |
|---|---|---|---|
| from | string | required | Sender address. Domain must be verified in /admin/domains. |
| to | string | array | required | Single address or array of up to 50 BCC-style recipients (no cross-leak). |
| subject | string | required | ≤ 255 chars. Liquid templating supported when contact_id is set. |
| html | string | required* | Or text. At least one body field must be set. |
| text | string | optional | Plain-text alternative. Auto-derived from html if absent. |
| tags | string[] | optional | Up to 20 tags for analytics filtering. |
| contact_id | string | optional | Links the send to a contact for unified analytics + Liquid merge fields. |
| attachments | array | optional | Up to 10 files, ≤ 25 MB total. Each: {filename, content_b64, mime}. |
| scheduled_at | RFC 3339 datetime | optional | Defer send. Up to 60 days ahead. |
curl -X POST https://api.blun.ai/v1/transactional/send \ -H "Authorization: Bearer $PULSE_KEY" \ -H "Idempotency-Key: order_84f3_confirm" \ -H "Content-Type: application/json" \ -d '{ "from": "hello@blun.ai", "to": "anna@workshop.de", "subject": "Your leather samples are on the way", "html": "<p>Hi Anna, your 3 swatches shipped today.</p>", "contact_id": "con_91Hk2", "tags": ["order_confirmation", "shipped"] }'
POST /v1/audience/contacts
Create or upsert a contact. Match-key is email per-workspace. Returns 201 on create, 200 on update. Triggers contact.created and any tag-added automations.
| Field | Type | Req? | Description |
|---|---|---|---|
| string | required | RFC 5322 valid; lowercased + suppression-list checked before insert. | |
| first_name | string | optional | ≤ 80 chars. |
| tags | string[] | optional | Existing or new — auto-created if not present. |
| consent | object | required (EU) | {source, ip, ts, double_opt_in_at} — written to consent ledger. |
const resp = await pulse.audience.contacts.upsert({ email: "giulia@conceria-castello.it", first_name: "Giulia", tags: ["leather", "italy", "verified"], consent: { source: "checkout_form", ip: "5.75.143.92", ts: "2026-05-04T14:18:00Z", double_opt_in_at: "2026-05-04T14:19:42Z" } });
POST /v1/campaigns
Create a campaign in draft. Use /campaigns/:id/send to ship, or set scheduled_at to defer. The same shape powers A/B variants — pass an array under variants with split percentages.
A/B variants
{
"name": "Product launch update",
"audience": { "tag": "newsletter" },
"variants": [
{ "key": "A", "split_pct": 50, "subject": "Product launch update" },
{ "key": "B", "split_pct": 50, "subject": "How Anna sourced from Bergamo, Tirupur and Wenzhou — one chat" }
],
"winner_metric": "open_rate",
"winner_call_at_p": 0.05
}
POST /v1/automations/:id/trigger
Fire a custom event into an automation. The flow's trigger node decides whether the contact enters the flow. Idempotent on (automation_id, contact_id, event_key).
curl -X POST https://api.blun.ai/v1/automations/auto_welcome_v3/trigger \ -H "Authorization: Bearer $PULSE_KEY" \ -d '{ "contact_id": "con_91Hk2", "event_key": "checkout.completed", "data": { "order_id": "ord_84f3", "value_eur": 340 } }'
POST /v1/ai/subject-suggestions
Get 5 subject-line suggestions from the BLUN AI gateway. Inference is EU-only (Frankfurt + Falkenstein); inputs are dropped within 30 seconds; the model is never trained on your content.
| Field | Type | Req? | Description |
|---|---|---|---|
| body_html | string | required | Campaign HTML — used only for inference, not stored. |
| tone | enum | optional | direct · warm · punchy · formal. Default: matches your last 30 days winning patterns. |
| language | ISO-639-1 | optional | Auto-detected from body_html if absent. |
| avoid_emoji | boolean | optional | Default false. |
X-AI-Region: eu-de. Inputs + outputs are dropped within 30 s. We don't sign training-rights agreements with any model provider. Per-workspace opt-out at /admin/settings/ai. Data sheet →POST /v1/domains/:id/verify
Trigger DKIM / SPF / DMARC re-check for a domain. Idempotent. Returns current status + the exact DNS records you should publish.
POST /v1/suppressions
Add an address to your workspace suppression list. Hashed before storage; BLUN-Mailing will reject any future send to it (returns 422 with type=suppressed).
GET /v1/messages
List sent messages with cursor-based pagination. Filter by status (queued/sent/delivered/bounced/opened/clicked), tag, contact_id, or date_range.
Webhooks · signing v2
Each webhook is signed with HMAC-SHA256 over timestamp + "." + body. Headers: X-Pulse-Signature: v2,t=<ts>,sig=<hex>. Replay window 5 minutes. Old keys honored 30 days during rotation.
import { createHmac, timingSafeEqual } from "crypto"; function verify(req, secret) { const [_, t, sig] = req.headers["x-pulse-signature"].match(/v2,t=(\d+),sig=([a-f0-9]+)/); if (Date.now()/1000 - +t > 300) throw new Error("replay"); const expect = createHmac("sha256", secret).update(`${t}.${req.rawBody}`).digest("hex"); if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) throw new Error("sig"); }
Webhooks · event catalog
Webhooks · retries & ordering
If your endpoint returns non-2xx, BLUN-Mailing retries with exponential backoff: 5 s, 30 s, 2 min, 10 min, 30 min, 2 h, 6 h, 24 h (8 attempts over 24 h). After that the event lands in a dead-letter queue at /admin/webhooks/dlq. Ordering is best-effort per-resource; consume idempotently using the X-Event-Id header.
SDKs
First-party SDKs for the four most-asked-for runtimes. All wrap the same v1 surface and ship the same retry/idempotency defaults.
npm i @blun-ai/BLUN-Mailing
pip install BLUN-Mailing
composer require blun-ai/BLUN-Mailing
gem install BLUN-Mailing
Going via raw HTTP from another runtime? The OpenAPI 3.1 spec is at /api/openapi.json. Import into Insomnia, Bruno, or your generator of choice.

