API reference
Send transactional email via REST. to may be one address or a small array (max 50) for transactional fan-out — not marketing blasts. Blindrelay relays the message, exports delivery signals to your OTLP endpoint and webhooks, and does not retain content. Read the security model. Prefer terminals or agents? Use the CLI or the Agent Plugin with the same scoped API keys. Download the OpenAPI 3 spec for codegen and API explorers.
/v1/sendAccept a transactional message for one or more recipients (max 50), enqueue delivery, and return immediately with 202 Accepted. Each recipient is one envelope and one SMTP attempt; content is not retained. Deliveries on the same IP are paced.
Authentication
Bearer API key created on the API Keys (sign-in required) page.
Authorization: Bearer br_live_...
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Required | Sender address (must match a verified domain) |
to | string | string[] | Required | Recipient, or up to 50 addresses together with cc and bcc. Fan-out is one SMTP delivery each (paced). Not a campaign API. |
cc | string | string[] | Optional | Recipients shown in Cc. Each address is its own delivery and quota unit, and goes through suppression checks. When present, every copy shows To and Cc. |
bcc | string | string[] | Optional | Recipients never shown in any header. Each address is its own delivery and quota unit. |
subject | string | Required | Message subject (max 998 chars) |
html | string | Required | HTML body (max 1 MB) |
text | string | Optional | Plain-text alternative. Derived from the HTML when omitted. |
headers | object | Optional | Extra headers, e.g. Reply-To, or List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) on mail with an unsubscribe link — not on strictly transactional mail; the URL must accept POST without sign-in (see Deliverability). Headers Blindrelay sets itself are ignored. |
attachments | object[] | Optional | Paid plans. Up to 10 files of {filename, content_type, content_base64}, 10,240,000 decoded bytes in total. Each recipient costs one quota unit per started MiB of the message. |
tags | { key, value }[] | Optional | Up to 5 opaque labels, returned on this message's webhook events. Key a-z 0-9 _ (max 32), value A-Z a-z 0-9 _ . - (max 64), so a tag cannot hold an email address. An invalid tag answers 400 invalid_tag. |
Transactional only
Blindrelay is a transactional relay, not a campaign platform. Pass to as a string or a small JSON array (max 50). Each address becomes one envelope; the shared HTML stays in RAM once. SMTP submission is still one RCPT TO per DATA. For large lists, send from your application loop — do not treat this as a blast API.
Idempotency
Safe retries without duplicate sends — built into the API, not your application hacks. Send an Idempotency-Key header with every logical send. Duplicate requests within 24 hours return the original 202 response without re-sending.
Use one key per logical send. If your worker retries after a timeout, reuse the same key so crash recovery never double-sends. Issue a new key only when you intentionally send again (e.g. after a soft bounce).
| Header | Description |
|---|---|
Idempotency-Key | Optional. 24-hour TTL. Example: |
Example
The Idempotency-Key header makes this curl safe to retry if your client times out before reading the response.
curl -X POST https://api.blindrelay.app/v1/send \ -H "Authorization: Bearer br_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reset-2024-06-29-user-42" \ -d '{ "from": "noreply@example.com", "to": "user@example.com", "subject": "Reset your password", "html": "<p>Click <a href=\"...\">here</a> to reset.</p>", "text": "Click here to reset: ..." }'Templates: React Email and MJML
Keep templates in your own codebase. Render them to HTML in your app and send the result as html. Blindrelay stores no templates and only relays the finished message.
import { render } from '@react-email/render' const html = await render(<WelcomeEmail name={name} />) await fetch('https://api.blindrelay.app/v1/send', { method: 'POST', headers: { Authorization: `Bearer ${process.env.BLINDRELAY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from, to, subject, html }), })import mjml2html from 'mjml' const { html } = mjml2html(mjmlSource) // Send it as the html field of POST /v1/send, as above.Response
202 Accepted — message queued for delivery. Success is always202, not 200.{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "queued" }Monthly quota
Plans include a monthly message quota (each accepted send counts as one message). A dashboard warning appears at 80% of included volume. Paid plans allow overage billing until 150%, then sends return 429 quota_exceeded until the next billing period. A workspace can set a lower monthly cap on the Billing page or through PUT /v1/spend-cap; sends past it return 429 spending_cap_reached.
Error responses
| Code | HTTP | Description | Retry? |
|---|---|---|---|
invalid_payload | 400 | Validation failed, or the body, path id or query could not be parsed (field detail in message) | No — fix the request body |
unauthorized | 401 | Missing or invalid API key | No — check Authorization header |
scope_missing | 403 | API key not authorized to send email | No — create or rotate an API key in the dashboard |
domain_not_verified | 403 | Sending domain not registered or DNS not verified | No — complete SPF, DKIM, and DMARC in the dashboard |
recipient_suppressed | 403 | Recipient on suppression list (hard bounce, complaint, or soft bounce if enforced) | No — remove recipient from your list |
tenant_suspended | 403 | Workspace suspended | No — email info@blindrelay.app |
unknown_tenant | 404 | No active workspace for this API key | No — verify API key belongs to an active workspace |
quota_exceeded | 429 | Monthly quota hard stop (150% of plan volume) | Yes — after next billing period or plan upgrade |
spending_cap_reached | 429 | The monthly message cap this workspace set, below the 150% hard stop, is reached | No — raise or clear the cap, or wait for the next billing period |
rate_limit_exceeded | 429 | Per-domain send rate or hourly unique-recipient limit exceeded | Yes — after the rate window resets |
draining | 503 | Server draining for deploy | Yes — retry shortly with same Idempotency-Key |
queue_at_capacity | 503 | In-memory send buffer full | Yes — exponential backoff with same Idempotency-Key |
internal | 500 | Unexpected server error | Yes — limited retries with same Idempotency-Key |
Two 429 codes mean different things. rate_limit_exceeded is the send abuse limit (per-domain send rate, unique recipients per hour): wait for the window to reset. rate_limited is a request throttle on status and check endpoints and comes with a Retry-After header that says when to try again.
{ "error": { "code": "domain_not_verified", "message": "sending domain is not verified" } }Message status
GET/v1/emails/{id}Current delivery status of one message, by the id its send returned. Accepts an API key with the send or read scope; only your own workspace's messages are found. Look up by id only — there is no search by recipient, and the response carries no recipient, subject or body.
A sandbox API key answers sandbox: true here and in the list: its mail is reported as usual and never delivered. Status names match the webhook events. final is false while a later event can still change the status. accepted_at is when Blindrelay accepted the message; updated_at is the latest change. Statuses are kept for the delivery data window (lookup_window_hours, 72 hours by default). An unknown id, another workspace's id or an older one answers 404 email_not_found. Lookups are rate limited per workspace (429 rate_limited with Retry-After).
| Status | Meaning | Final |
|---|---|---|
queued | Accepted and waiting for, or in, its delivery attempt | No |
relay_accepted | Handed to the receiving mail server; a later report can still turn it into delivered or a bounce | No |
delivered | The recipient's mail server confirmed delivery | Yes |
soft_bounce | Temporary failure — your app may resend with a new idempotency key | No |
hard_bounce | Permanent failure — recipient suppressed | Yes |
complaint | The recipient reported the message as spam | Yes |
suppressed | Not sent: the recipient is on your suppression list | Yes |
failed | Blindrelay could not complete the delivery attempt (signing or SMTP error) | Yes |
{ "id": "550e8400-e29b-41d4-a716-446655440000", "status": "delivered", "summary": "Blindrelay delivered the message.", "final": true, "accepted_at": null, "updated_at": "2026-06-29T10:15:02Z", "lookup_window_hours": 72 }GET /v1/emailsRecent messages for the same data window, newest activity first: id, sending domain, status and timestamps, with no recipient or content. Accepts an API key with the send or read scope. Optional status and domain filters; page_size (default 50, at most 100) and the cursor returned as next_cursor page through the list. Lists are rate limited per workspace, separately from status lookups.
Check an email before sending
POST/v1/emails/checkStatic preflight of subject, html, text and from (the /v1/send field names; omitted fields are skipped). Accepts an API key with the send or read scope, needs no verified domain and does not count against your quota. Never fetches a link — reset, verification and unsubscribe links can trigger actions — and the content is checked in memory, never stored or logged.
ok is false when any error-severity finding exists; warnings do not fail the check. Each finding has a stable code, its field and, when it points into the content, a 1-based line and character column. Messages never quote your content. At most 100 findings are listed (truncated; the counts stay complete). Fields over the /v1/send limits answer 413 payload_too_large, malformed JSON 400 invalid_payload, and checks are rate limited per workspace (429 rate_limited). The same rules run locally in blindrelay check, in the MCP email_check tool and on the Email check (sign-in required) page.
| Code | Severity | Finds |
|---|---|---|
unresolved_template_variable | error | An unrendered {{ … }}, {% … %}, ${…}, %%…%%, *|…|*, [[…]] or variable-like {…} in the subject, sender, HTML or text. |
link_empty | error | An href that is empty or just #, or an <img> without src. |
link_javascript | error | A javascript: or vbscript: link (also when entity-encoded). |
link_placeholder | error | A link to example.com/.org/.net, localhost, 127.0.0.1, a reserved TLD, your-domain / yourdomain, or a TODO, CHANGEME or xxx host or path segment (HTML and plain text). |
link_relative | error | A relative or protocol-relative URL; mail clients cannot resolve it. |
link_insecure | warning | An http:// link; use https://. |
unclosed_tag | error | An element that is never closed (optional end tags such as <p>, <li> and <td> are fine). |
mismatched_tag | error | An element closed by an outer closing tag before its own. |
stray_closing_tag | error | A closing tag with no matching opening tag. |
unclosed_comment | error | A <!-- comment that never ends; everything after it is hidden. |
script_tag | error | <script>: mail clients strip it. |
iframe_tag | error | <iframe>: mail clients strip it. |
form_tag | warning | <form>: most mail clients disable or strip it. |
external_stylesheet | warning | <link rel="stylesheet">: most mail clients ignore it. |
img_missing_alt | warning | An <img> without alt (alt="" is fine for decorative images). |
text_alternative_missing | warning | HTML without a plain-text part. |
subject_empty | warning | A subject that was given but is blank. |
html_too_large | warning | HTML over about 102 KB, which Gmail clips. |
body_missing | error | Neither HTML nor plain text. |
from_placeholder | error | A sender address on a placeholder domain. |
curl -X POST https://api.blindrelay.app/v1/emails/check \ -H "Authorization: Bearer br_live_..." \ -H "Content-Type: application/json" \ -d '{ "subject": "Reset your password", "html": "<p>Hi {{ name }}, <a href=\"https://example.com/reset\">reset</a></p>" }' { "ok": false, "error_count": 2, "warning_count": 1, "truncated": false, "findings": [ { "code": "unresolved_template_variable", "severity": "error", "field": "html", "message": "Unresolved template variable ({{ … }}): render the template before sending.", "location": { "line": 1, "column": 7 } }, { "code": "link_placeholder", "severity": "error", "field": "html", "message": "`<a href>` points to a placeholder or local address.", "location": { "line": 1, "column": 28 } }, { "code": "text_alternative_missing", "severity": "warning", "field": "text", "message": "No plain-text part: …", "location": null } ] }Setup status
GET/v1/setupWhere the workspace's setup stands, for any API key scope: connected, domain registered, DNS records published, domain verified and test send, in that order. The dashboard checklist, the CLI blindrelay setup command and the MCP setup_status tool read the same steps.
Each step has done, an optional blocked_reason, the scopes your key lacks for it (missing_scopes) and a next_action with the dashboard page, CLI commands and MCP tools that finish it. dns_published lists the records the last DNS check did not find. plan_restrictions names plan limits that block a step: Free keys carry only send, so the domain steps happen in the dashboard. The endpoint never sends; the test email is a normal POST /v1/send after you confirm it.
{ "workspace": { "name": "Acme", "plan": "starter" }, "caller": { "kind": "api_key", "scopes": ["read", "write"] }, "complete": false, "next_step": "dns_published", "domain": { "id": "2f1c0c3e-…", "domain": "mail.acme.dev", "status": "pending" }, "steps": [ { "id": "connected", "done": true, "next_action": null, … }, { "id": "domain_registered", "done": true, "next_action": null, … }, { "id": "dns_published", "done": false, "missing_scopes": [], "next_action": { "summary": "Publish the SPF, DKIM and DMARC records …, then run verification.", "ui_path": "/domains", "cli": ["blindrelay domains dns-guide 2f1c0c3e-…", "blindrelay domains verify 2f1c0c3e-…"], "mcp_tools": ["domains_dns_guide", "domains_verify"], "required_scopes": ["read", "write"] }, "pending_records": [ { "record": "dkim", "type": "TXT", "host": "br202611._domainkey.mail.acme.dev", "code": "missing", … } ] }, { "id": "domain_verified", "done": false, "blocked_code": "previous_step_pending", … }, { "id": "test_send", "done": false, "missing_scopes": ["send"], "blocked_code": "previous_step_pending", … } ], "plan_restrictions": [], "missing_scopes": ["send"] }Health checks
Use these endpoints for uptime monitors and load balancer probes. No authentication required.
GET/healthLiveness — returns { status: ok, version } when the process is running.
GET/readyReadiness — returns 200 when accepting sends; 503 with reason draining during deploy.
Webhook events
Configure a webhook URL on the Delivery (sign-in required) page. Each payload carries the event name, tenant id, envelope id, and sending domain, plus tags when the send set them and "sandbox": true for a sandbox API key. Events:
| Event | Meaning |
|---|---|
relay_accepted | Handed to the receiving mail server |
delivered | The recipient's mail server confirmed delivery |
soft_bounce | Temporary failure — your app may resend with a new idempotency key |
hard_bounce | Permanent failure — recipient suppressed |
failed | Blindrelay could not complete the delivery attempt (signing or SMTP error) |
complaint | The recipient reported the message as spam — recipient suppressed |
Spam complaints are not sent to webhooks: they arrive later, from the mailbox provider, and show in message status (complaint) and OTLP (email.complaint.total).
{ "event": "delivered", "tenant_id": "550e8400-e29b-41d4-a716-446655440000", "envelope_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "domain": "example.com" }Each event is POSTed with a 5 second timeout. A network error, 5xx or 429 is retried with exponential backoff, 12 attempts over about 24 hours; any other 4xx ends it, and so does removing the webhook. Answer 2xx quickly and process afterwards. To see whether your endpoint keeps up, GET /v1/delivery-settings (add ?domain_id= for a domain override) returns diagnostics: attempt and failure counts since the webhook URL was last changed, the last attempt time, its HTTP status and outcome (ok, timeout, connect_failed, tls, auth_failed, http_4xx, http_5xx, …). POST /v1/delivery-settings/test answers with the same category and http_status and is not counted. No URL, payload or response body is stored.
A webhook gets a signing secret when it is first configured, returned once by that save. Each request carries Blindrelay-Signature: t=<unix>,v1=<hex>, the HMAC-SHA256 of <t>.<delivery id>.<body>, and Blindrelay-Delivery-Id, which stays the same across every attempt of one event: use it to drop duplicates. A webhook set up before signing existed stays unsigned until you generate a secret with POST /v1/delivery-settings/rotate-signing-secret (optional domain_id; needs a write key). A rotation returns the new secret once, and for 24 hours from the rotation each request also carries a v1 made with the replaced secret.
POST https://app.example.com/webhooks/blindrelay Content-Type: application/json Blindrelay-Signature: t=1700000000,v1=bbc9e13024abefb01b0cba3e34e5399bf159a3354dbb65fd110d566e0bb8b7cf Blindrelay-Delivery-Id: 7a1c44e0-93b2-4f6d-8e15-02d9b3c7a6f4 {"event":"delivered","tenant_id":"00000000-0000-0000-0000-000000000000","envelope_id":"00000000-0000-0000-0000-000000000000","domain":"example.com"}// Node.js: rawBody is the request body as a Buffer, headers are lower-cased const crypto = require('crypto') function verifyBlindrelayWebhook(rawBody, headers, secret) { const parts = headers['blindrelay-signature'].split(',').map((p) => p.trim().split('=')) const t = Number(parts.find(([k]) => k === 't')?.[1]) const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v) if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false const signed = `${t}.${headers['blindrelay-delivery-id']}.` const expected = crypto.createHmac('sha256', secret).update(signed).update(rawBody).digest('hex') return signatures.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) }Inbound HTTPS delivery
On a paid plan, an inbound alias can deliver to your own HTTPS endpoint (a mailbox destination also needs an active dedicated IP). Each forwarded message counts as one message of your plan. Blindrelay POSTs the raw message as received, signed, and keeps no copy: nothing is stored and the body stays in memory for the one request. Manage endpoints on the Inbound email (sign-in required) page or with these routes (read to list, write for the rest).
/v1/inbound-forward/endpointsEndpoints with name, URL, the number of aliases using each, the rotation overlap and content-free delivery diagnostics (the webhook shape). Never returns a signing secret.
POST/v1/inbound-forward/endpointsCreate from { name, url }. The URL must be https on a public address. The response carries signing_secret once.
PATCH / DELETE/v1/inbound-forward/endpoints/{id}Rename or change the URL (a new URL resets the diagnostics); delete answers 409 endpoint_in_use while an alias delivers to it.
POST/v1/inbound-forward/endpoints/{id}/rotate-secretNew signing secret, shown once. For 24 hours from the rotation each delivery also carries a signature made with the replaced secret.
POST/v1/inbound-forward/endpoints/{id}/testPosts a fixed sample message with Blindrelay-Test: 1 and answers { success, message, category, http_status }; not counted in the diagnostics.
Point an alias at an endpoint with endpoint_id instead of destination on POST /v1/inbound-forward/aliases or PATCH /v1/inbound-forward/aliases/{id}.
POST https://app.example.com/inbound Content-Type: message/rfc822 Blindrelay-Signature: t=1700000000,v1=3b9061d8fbef2af0b480b47483b380677513d671d493434ef7544e790d9db9fd Blindrelay-Delivery-Id: 0f8e2a54-7c1b-4d3e-9a60-5b21c4d8e7f1 Blindrelay-Alias: support@example.com Blindrelay-Endpoint-Id: 0b7e2a51-8d3c-4a55-b3c4-6f1e9d2c7a88 From: a@example.com Subject: Hi Hello
v1 is the hex HMAC-SHA256 of <t>.<delivery id>.<alias>.<raw body> keyed with the signing secret, where the delivery id and alias are the Blindrelay-Delivery-Id and Blindrelay-Alias headers. Accept the request when any v1 matches and t is within 5 minutes of your clock. Test vector: secret brsig_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, t=1700000000, the delivery id and alias above and the body From: a@example.com\r\nSubject: Hi\r\n\r\nHello\r\n (CRLF line ends) give the v1 shown above.
// Node.js: rawBody is the request body as a Buffer, headers are lower-cased const crypto = require('crypto') function verifyBlindrelay(rawBody, headers, secret) { const parts = headers['blindrelay-signature'].split(',').map((p) => p.trim().split('=')) const t = Number(parts.find(([k]) => k === 't')?.[1]) const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v) if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false const signed = `${t}.${headers['blindrelay-delivery-id']}.${headers['blindrelay-alias']}.` const expected = crypto.createHmac('sha256', secret).update(signed).update(rawBody).digest('hex') return signatures.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) }| Your endpoint answers | What happens |
|---|---|
2xx | Delivered. |
408, 425, 429, 5xx, redirect, timeout (15 s), connection or TLS failure | Deferred: the mail server keeps the message and retries; Blindrelay stores nothing. |
413 | Bounced to the sender as too large. |
Any other 4xx (including 401 and 403) | Bounced to the sender with a fixed reason; your reply text is never passed on. |
Observability (OTLP)
Configure the collector endpoint in the app under OTLP (sign-in required) . Metric names below are the export contract (not a Blindrelay REST API).
Blindrelay pushes delivery counters to your OTLP collector after each send outcome. Use Webhooks when your app needs a per-message callback with envelope_id.
| Metric | Meaning |
|---|---|
email.sent.total{status=relay_accepted} | Handed to the receiving mail server. |
email.sent.total{status=delivered} | The recipient's mail server confirmed delivery. |
email.delivery.latency | Time to hand off on the SMTP path (histogram, seconds). |
email.bounce.total{type=hard} | Hard bounce — permanent failure; recipient suppressed. |
email.bounce.total{type=soft} | Soft bounce — temporary failure; your app may resend. |
email.failed.total | Blindrelay could not sign or submit (not a bad recipient). |
email.complaint.total | Spam complaint from a mailbox provider. |
email.webhook.attempt.total{outcome, http_status_class, scope} | One webhook POST: outcome ok or a failure category, HTTP status class (2xx–5xx or none), workspace or domain hook. |
Also labeled by domain and optional api_key_id. One send can increment Accepted and later Delivered.
Delivery model
Blindrelay attempts delivery once per accepted send. Message content stays in process memory while queued and until the signed MIME payload is built, then the body is released. A send buffer may remain until the SMTP response is read. After a soft bounce, your application decides whether and when to call POST /v1/send again.
Ready to send?
The free plan includes 3,000 messages a month with no credit card. Verify a domain and send your first message in minutes.
Start free See pricing