Send an Email
message.delivered webhook. A fully-clean dispatch returns 202; a partially-failed multi-recipient dispatch returns 207:
succeeded carries one row per dispatched recipient; when any recipient failed, each failed row keeps to plus the actionable code / message / details so the dashboard renders a per-recipient hint.
to accepts a single address or an array. from and reply_to accept a bare address (support@acme.io) or the RFC-5322 mailbox form with a display name (Acme Support <support@acme.io>); cc and bcc accept either form or an array of either. Either html or text must be non-empty — a body-less send is rejected before any message is queued.
Using Templates
Templates are a two-step flow: render your email-builder blocks into a complete HTML document, then send that HTML through the standard send endpoint. The direct/messages/email endpoint takes a finished html (or text) body — it does not accept a template_id or variables.
Step 1 — render blocks to HTML with POST /api/v1/messages/email-builder/render. Merge values inline using {{...}} tags in your block content:
text/x-amp-html, which non-AMP inboxes ignore), and a templateId you can correlate with a later save:
POST /api/v1/messages/email:
Save, list, and reuse templates
Rendering is stateless — nothing is persisted byrender. To reuse a definition, save it and list your saved templates:
- Save with
POST /api/v1/messages/email-builder/save— body shape mirrorsrender(name,blocks, optionalid,previewText,metadata). The content is stored as a{body, blocks}envelope: the plain-textbodyis extracted from header, text, button, and footer blocks (capped at 8,192 characters) and serves list snippets and send-time fallbacks;blocksre-hydrates the builder canvas. Re-saving the same name without anidupdates the existing template in place; passing anidthat collides with a different template’s name returns 409TEMPLATE_NAME_ALREADY_EXISTS. - List with
GET /api/v1/messages/email-builder/list— returns your tenant’s saved email-builder templates (channelemail), freshest-first, each with its editableblocksarray, rendered plain-text body,previewText, and metadata. Cursor-paginate with the opaquepagination.cursorvalue;limitdefaults to 50 (max 50). This is the same list the dashboard “Load template” picker uses to re-hydrate the canvas. - Render the loaded template by passing its
blocks(and optionalid) back to/render, then send as above.
templates:read or templates:write scope.
Personalization with merge tags
Inline{{...}} tags in block content are preserved verbatim by the renderer so the personalization layer can substitute values at send time. URL-bearing fields (url, logoUrl, href, src, action) pass through when they contain a single merge tag like {{contact.unsubscribeUrl}}; every other URL value is normalized — bare hosts get https:// prepended, and unsafe schemes (javascript:, data:, file:, etc.) are neutralized to #. A tag that was never resolved lands on the wire as the literal {{...}} string, so resolve every merge before you send.
If name in the render payload matches an AMP amp-list block’s titleField, the {{title}} substitution happens inside the AMP document. Keep merge keys to safe identifier characters (letters, digits, dots, underscores) — anything else falls back to title.
Features
- Transactional email — receipts, notifications, password resets
- Marketing email — campaigns, newsletters, announcements
- Block-builder templates — save, list, and render drag-and-drop block templates
- Attachments — capped sizes with a MIME allowlist (see Limits). Each attachment requires a
content_type. - Custom domains — send from your own domain with DKIM, SPF, and DMARC (see Domain Setup)
- Tracking — open tracking and click tracking with engagement webhooks (see Engagement Tracking)
- Suppression lists — automatic bounce and complaint handling plus self-service management (see Bounce and Complaint Handling)
Domain Setup
To send from your own domain, add the following DNS records in Channels > Email > Domains:
Orbit validates your DNS — verification typically completes within minutes. The DKIM
p= public key is extracted and bit-length-validated (minimum 1,024 bits per RFC 8301), so a placeholder record with no real signing key never flips to verified.
Verification fails or regresses
Verification is not one-shot: a daily health check re-validates your DNS and the interactive Verify DNS button (POST /api/v1/settings/channels/email/verify-dns) re-checks on demand. If a record drifts (TTL expired, host edited, key rotated), the failure is written to the domain’s dns-history and a bell notification flags the regression. Resolution: fix the record, re-run Verify DNS, and confirm the traffic-light DNS status (GET /api/v1/email/domains/:domainId/dns-status) is green before resuming sends. A 403 from the provider with “domain is not verified” is the same class — re-verify in Senders.
Trigger a re-check and poll the per-record traffic-light view:
valid | warning | invalid | unknown and the roll-up overallStatus is the worst-of:
warning or invalid the same payload lists the fix under issues; an unknown verdict is a resolver timeout — re-poll after a few seconds.
Multiple domains and subdomains
You manage multiple sending domains through the Email/Senders surface (GET|POST|PUT|DELETE /api/v1/email/senders), each with its own verification state, and any verified domain is available in the compose dropdown. The id default always resolves to the platform orbit.devotel.io domain. Subdomains (e.g. mail.example.com) are registered as their own sender entries with their own DNS records.
Warm-up posture
New domains have no sender reputation — do not send marketing volume immediately, even though the platform will accept it. Ramp gradually and watch the aggregate soft-bounce and complaint rates onGET /api/v1/email/suppressions/reputation. If you plan dedicated-IP volume, the warmup ramp endpoints (GET /api/v1/email/warmup-plan and GET /api/v1/email/warmup-status) return a recommended daily cap schedule and today’s headroom, and GET /api/v1/email/dedicated-sending/eligibility returns the dedicated-IP eligibility check.
Limits
- Send rate — 200 sends per minute per tenant on the email channel, with an idle per-tenant override surface (folder-shared pool; a request burst that trips the cap returns 429
RATE_LIMITED/channel-limited). Template saves cap at 60/min; send-test caps at its own bucket. - Attachments — up to 20 attachments per email, 25 MB total, 10 MB per file. The refinement runs against declared
sizevalues; oversize or over-count requests fail validation. - Attachment MIME allowlist — PDF, Word (
doc/docx), Excel (xls/xlsx), PowerPoint (ppt/.pptx),text/csv,text/plain,application/zip,image/jpeg,image/png,image/gif,image/webp, common audio (mpeg,ogg,wav,aac,amr), andvideo/mp4/3gpp.image/svg+xmlis explicitly blocked — SVG may embed scriptable XML, so it stays off the same way the file-upload service excludes it. Executables, scripts, and unknown MIME types are rejected. A blocked attachment fails with 422VALIDATION_ERRORat the schema edge and never reaches the provider. - Body — either
htmlortextmust be non-empty; there is no byte cap on the body itself, but the 998-character caps on subject and each address field apply. - Recipient count per send —
to/cc/bccaccept arrays within the same per-request rate limit; large recipient sets belong in the bulk/batch endpoint (POST /messages/bulk), which admits up to 10,000 recipients per call with per-row outcomes.
Engagement Tracking
Tracking is configured per sending domain, not per send — the email provider applies open/click tracking at the domain level. Passingmetadata.track_opens or metadata.track_clicks in a send is deprecated and has no effect (accepted for backward compatibility; a deprecation warning is logged). On the platform’s own side, Orbit additionally mints a tenant-side open pixel and rewrites click-tracking links so the open/click signal hits Orbit’s edge directly regardless of the provider’s per-domain toggle.
- Open tracking — a 1×1 transparent pixel
<img>is injected just before</body>(or appended when the HTML has no closing body tag; the plaintext alt-body is never touched). The URL is HMAC-signed and bound to(tenant, message, timestamp). Machine-open classification (Apple Mail Privacy Protection, Gmail Image Proxy, bot prefetch) stampsis_machine_openso dashboards can distinguish inflated from genuine engagement; first-event-wins dedup keepsopened_ataccurate against proxy fan-out. - Click tracking — tracked links are rewritten to an Orbit redirect endpoint; the original URL rides as an HMAC-verified payload. If a link can’t safely be rewritten, it ships untracked rather than failing the send. Click payloads carry
link,ipAddress,userAgent, and the click’s owntimestamp.email.clickedfans out on every distinct click, whileemail.openedrecords first-open-only.
Bounce and Complaint Handling
Delivery outcomes arrive on the channel-agnosticmessage events (message.delivered, message.failed with channel: "email"). A recipient-side bounce resolves to the terminal bounced status; the DSN classification drives the suppression lifecycle below. The diagnostic tree for decoding 5.x.x hard bounces vs 4.x.x soft vs complaints vs spam-traps lives in Troubleshooting: email bounces and spam complaints.
Automatic suppression lifecycle
Suppression reasons the platform writes automatically:
Sends to a suppressed address flip to 422
RECIPIENT_OPTED_OUT at the pre-send gate, and the monthly quota slot the send consumed is refunded — you are never charged for a send we refused to hand to the provider.
Viewing and managing the list
The suppression list is self-service under Email > Suppressions (owner/admin). The aggregate reputation read is the one you poll while you ramp:GET /api/v1/email/suppressions— page/search/filter by reasonGET /api/v1/email/suppressions/reputation— aggregate sent / delivered / bounced / complaint counts and rates, health tier and score, and current list sizeGET /api/v1/email/suppressions/export— CSV downloadPOST /api/v1/email/suppressions— add a manual suppression (a recipient who asked by phone, a known-bad import, etc.)POST /api/v1/email/suppressions/bulk-import— import a CSV or address list from a previous ESPDELETE /api/v1/email/suppressions/:id— un-suppress an address
Error Catalog
Every API error returns the standard envelope{ error: { code, message, status }, meta: { request_id, timestamp } }. The email send path can throw:
Provider error passthrough. A
403 from the provider (for example, a sending domain that is not yet verified) reaches you as 403 VALIDATION_ERROR with a platform-safe message and the upstream detail preserved on details.provider_message. An attachment rejected over size/MIME fails 422 VALIDATION_ERROR locally and never reaches the provider. A Resend 429 carries status 429 end-to-end so burst traffic is treated as safe backpressure rather than a provider outage.
Webhook Events
Email engagement fires its own event types:
Delivery outcomes are reported on the channel-agnostic message events, not on
email-specific types. Subscribe to these to track delivery, bounces, and complaints:
Each
message event carries channel: "email" and the originating message_id,
so you can filter for email traffic and correlate back to the send.