Send one outbound email per recipient from a FORM or CONTACT_FORM integration using that integration's outboundMail SMTP. The call runs server-to-server with X-API-Key and scope ``transactional.send``.
This is not a stateless SMTP relay. Mailoo creates a full send lifecycle inside the integration: TransactionalSend, Message (OUTBOUND), optional Subscriber sync, open/click tracking, and optional delivery webhooks. See outbound-smtp{.interpreted-text role="doc"} for how this differs from exposing SMTP credentials to integrators.
Related: SMTP layer --- outbound-smtp{.interpreted-text role="doc"}; unsubscribe tokens --- website-forms{.interpreted-text role="doc"}; OpenAPI --- https://api.mailoo.app/docs/v1.
Architecture
- Trigger, not relay: Your backend calls Mailoo; Mailoo renders the message, persists an OUTBOUND
Message(initiallyPENDING), then sends via ``integration.config.outboundMail`` (same SMTP as dashboard replies and welcome mail). Rendering/persist and SMTP are separate steps: a delivery failure does not drop the stored message (status becomesFAILED;htmlContentremains for dashboard preview). - Secrets: Use ``X-API-Key`` with ``transactional.send`` (or FULL) from your server or BFF only --- never in browser bundles.
- Idempotency: Every request requires ``idempotencyKey`` (max 256 chars, unique per integration). First success returns 201; replays return 200 with
idempotentReplay: true. - Subscribers: Mailoo upserts a
Subscriberfor the recipient email. IfunsubscribedAtis set, send is skipped (409, statusSKIPPED_UNSUBSCRIBED) and noMessageis created. - Delivery events: Optional outbound webhooks (
delivered,opened,clicked,unsubscribed,bounced) with HMAC signing and retries --- see Delivery webhooks below.
Send endpoint
``POST {baseUrl}/api/v1/webhooks/transactional/{projectUid}/{integrationId}/send``
Headers: Content-Type: application/json, X-API-Key (scope ``transactional.send`` or FULL)
Body (JSON):
- ``idempotencyKey`` (required) --- stable key for this logical send (e.g.
daily-2026-06-20-user123) - ``to`` (required) --- recipient email
- ``name`` (optional) --- display name; used for
{{User.name}}/{{firstName}} - ``templateId`` (optional) --- campaign id (dashboard campaign on this integration); use either
templateIdor inlinesubject+text/html - ``locale`` (optional) --- recipient locale for email block/template/slot overlays (BLOG-style
localesJSON). Omitted or missing overlay falls back to canonical EN and is logged. When set, also stored onSubscriber.locale. - ``vars`` (optional) --- object of string/number/boolean Liquid variables (see below)
- ``subject``, ``text``, ``html`` (optional) --- inline content when not using
templateId; subject and at least one oftextorhtmlrequired for inline sends
Responses:
- 201 --- email sent (
status: SENT,idempotentReplay: false) - 200 --- idempotent replay of a prior send
- 409 --- recipient unsubscribed (
status: SKIPPED_UNSUBSCRIBED) - 502 --- SMTP delivery failed (
TransactionalSendstatus: FAILED; OUTBOUNDMessageis kept withstatus: FAILEDand rendered body for preview;BOUNCEDdelivery webhook is recorded) - 400 --- validation error
- 403 --- API key missing ``transactional.send``
- 404 --- project/integration not found or not FORM/CONTACT_FORM
Example (inline):
curl -X POST "https://api.mailoo.app/api/v1/webhooks/transactional/{projectUid}/{integrationId}/send" \
-H "Content-Type: application/json" \
-H "X-API-Key: mai_live_..." \
-d '{
"idempotencyKey": "daily-2026-06-20-user1",
"to": "user@example.com",
"name": "Jane Doe",
"subject": "Hi {{firstName}}",
"html": "<p>{{affirmationText}} <a href=\"{{writeUrl}}\">Write</a></p><p><a href=\"{{unsubLink}}\">Unsubscribe</a></p>",
"vars": {
"affirmationText": "You are enough.",
"writeUrl": "https://app.example.com/write"
}
}'
Example response (201):
{
"success": true,
"data": {
"sendId": "clx...",
"messageId": "clx...",
"status": "SENT",
"idempotentReplay": false
}
}
Liquid variables
Templates (campaign templateId or inline subject/text/html) are rendered with LiquidJS before SMTP send. Existing {{User.name}} / {{ firstName }} tags remain valid.
Built-in context:
- ``User.email``, ``User.name`` --- from
to/name - ``firstName`` --- from
vars.firstNameor first token ofname - ``unsubLink`` --- one-click unsubscribe URL (per-integration signing secret + API ``MAILOO_PUBLIC_APP_URL``)
- ``affirmationText``, ``writeUrl``, ``streak`` --- from
varswhen provided (common for retention/nudge flows)
Additional string/number/boolean keys in vars are merged into the context.
Open and click tracking
When HTML body is non-empty, Mailoo injects:
- A 1Γ1 open-tracking pixel
- Rewritten
https://(and//) links via click-tracking redirects
Public routes (no API key):
- ``GET /api/v1/public/transactional/open/{trackingToken}`` --- returns transparent GIF; records first open
- ``GET /api/v1/public/transactional/click/{trackingToken}?u={urlencodedTarget}`` --- records click, 302 redirect to target (unknown token β 404, no redirect)
URL shape: one opaque trackingToken per TransactionalSend (no recipient email in the public URL). Destination is the percent-encoded u query parameter. When wrapping HTML, Mailoo decodes & (and related entities) in href values so UTM query strings stay intact after redirect.
Unique vs raw clicks: Mailoo stores unique engagement only --- TransactionalSend.clickedAt (and CampaignRecipient.clickedAt when a campaign is linked) is set on the first recorded click and not overwritten. There is no server-side raw click counter or per-CTA link id. A repeat click still emits a clicked delivery webhook (when configured) with metadata.url, but dashboard/API counts remain unique-first. Client or proxy prefetch (for example Gmail / Outlook Safe Links) can inflate unique counts.
Open-redirect note: Destination u is not HMAC-signed; a known token can still be reused with a substituted u. Prefer keeping tracking links as Mailoo rewrote them. Unknown tokens do not redirect.
Set ``MAILOO_PUBLIC_API_URL`` on the API host so pixel and click URLs point at your public API. Set ``MAILOO_PUBLIC_APP_URL`` when unsubscribe and app links must point at the dashboard origin.
Delivery webhooks
When configured, Mailoo POSTs JSON events to your notification endpoint.
Configuration (per integration):
integration.config.deliveryWebhook.url and .secret --- set in the dashboard (Delivery webhooks on the integration Setup tab) or via PATCH β¦/integrations/{id} with deliveryWebhook.
Events: delivered (after successful SMTP), opened, clicked, unsubscribed (after unsubscribe; still emitted for legacy consumers), bounced (SMTP failure), consent.changed (list membership boolean --- unsubscribe and resubscribe / Restore / lifecycle consent flip).
Payload shape (JSON):
{
"event": "delivered",
"integrationId": "...",
"messageId": "...",
"idempotencyKey": "...",
"recipientEmail": "user@example.com",
"timestamp": "2026-06-20T12:00:00.000Z",
"metadata": {}
}
consent.changed adds top-level externalUserId, marketingConsentGranted, and unsubscribedAt (ISO or null). See lifecycle-events{.interpreted-text role="doc"}.
Signing: Header ``X-Mailoo-Signature: t={unixSeconds},v1={hex}`` where v1 is HMAC-SHA256 of "{timestamp}.{rawBody}" using the webhook secret. Header ``X-Mailoo-Event`` repeats the enum name (e.g. DELIVERED, CONSENT_CHANGED).
Host verify (``@mailoo/forms``): verifyMailooDeliveryWebhook / createDeliveryWebhookHandler from @mailoo/forms/webhooks and @mailoo/forms/routes.
Retries: Outbox worker (10s interval on the API process) retries failed deliveries up to 8 attempts with exponential backoff (max 5 minutes). Set ``DISABLE_DELIVERY_WEBHOOK_WORKER=1`` only for tests or if you run a separate processor.
Prerequisites
- FORM or CONTACT_FORM integration with ACTIVE status
- ``outboundMail`` configured (dashboard β Connection & settings β Outbound email)
- ``MAIL_SMTP_ENCRYPTION_KEY`` on the API when SMTP password or unsubscribe secret is stored encrypted
- RESTRICTED or FULL API key with ``transactional.send``
Unsubscribe signing
Each FORM/CONTACT_FORM integration stores an encrypted unsubscribe HMAC secret in integration.config. Mailoo generates {{unsubLink}} automatically in transactional templates (confirm page under ``MAILOO_PUBLIC_APP_URL``). Owners can rotate the secret in the dashboard (invalidates links in already-sent mail). External backends that sign their own links need the rotated plaintext once, or should call ``POST /api/v1/webhooks/unsubscribe`` with an API key instead.
RFC 8058 List-Unsubscribe: Transactional, campaign, and lifecycle sends via sendTransactionalEmail also set List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click pointing at ``POST /api/v1/public/unsubscribe/one-click?email=&integrationId=&token=`` (base ``MAILOO_PUBLIC_API_URL``, same as open/click tracking). Providers POST form-urlencoded List-Unsubscribe=One-Click (or an empty body); the endpoint is idempotent and returns plain OK. Dashboard Create reply and Offer Generator mail do not add these headers.