Connect your website subscription forms to Mailoo. Each valid submission creates an inbound message and upserts a subscriber. Use a FORM integration and call the API from your server or BFF only.
Quick start
- Create a project and a Form integration in the Mailoo dashboard (or via MCP
create_integrationwithtype=FORM). - Generate an API key. For RESTRICTED keys, include
webhook.form-submission. Agents can also configure Connection & settings, templates, subscribers, campaigns, and messages via MCP tools undermanage_form_*(scopeform.manage/ MCP token) --- see/development/mailoo-mcp{.interpreted-text role="doc"}. - Store the key and IDs in server environment variables.
- Post form data to your own route; that route forwards to Mailoo.
- Confirm the message and subscriber in the dashboard.
:::: important ::: title Important :::
Server-side only. The browser must not hold X-API-Key. Do not put Mailoo credentials in client-side JavaScript.
::::
:::: note ::: title Note :::
Legacy (not recommended): Calling Mailoo webhooks directly from the browser can work if CORS and allowed origins are configured, but it exposes the API key. Do not use this for new integrations. ::::
Next.js with @mailoo/forms
For Next.js App Router hosts, prefer the @mailoo/forms package: same-origin BFF route factories, typed submit bodies, and client hooks. See website-forms-nextjs-example{.interpreted-text role="doc"}.
For identified-user registration or activation (not anonymous newsletter submit), emit lifecycle events from your server --- see lifecycle-events{.interpreted-text role="doc"}.
Create the integration
- Go to Dashboard β Projects β [Your Project] β Integrations.
- Create a new integration and select Form Integration.
- Set Name, Status (Active), and optional Allowed origins (scheme + host, e.g.
https://example.com). If the list is empty, Origin is not checked. Auth remains the API key. A BFF that forwards the browserOriginstill enforces a filled list.
Request body
Your server sends JSON to Mailoo:
email--- requiredname--- optionalsubject--- optional (defaults such as"New subscription")content--- optional (defaults such as"New subscription from {email}")source,metadata--- optional, stored with the message
A campaign (welcome / reply template) is not required for the form to accept submissions. The inbound message is built from the request body (or defaults).
Redirecting the user after a successful submission is entirely your site's responsibility. Mailoo does not provide a redirect URL.
API endpoint
POST /api/v1/webhooks/forms/{projectUid}/{integrationId}
Headers: Content-Type: application/json, X-API-Key (required). Optional Origin when you use an allowed-origins list.
Example body:
{
"email": "john@example.com",
"name": "John Doe",
"source": "https://yoursite.com/newsletter",
"metadata": {
"type": "newsletter_subscription"
}
}
New subscriber --- message created:
{
"success": true,
"messageId": "msg_abc123def456",
"message": "Form submission processed successfully"
}
Already subscribed (same email) --- no new message; unsubscribedAt is cleared if needed; response omits messageId:
{
"success": true,
"message": "Already subscribed"
}
Validation errors return error and message (for example "Email is required", "Invalid email format").
Server-side example (Express)
The form posts to your route. Your route calls Mailoo:
app.post('/api/subscribe', async (req, res) => {
const { email, name } = req.body
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' })
}
const response = await fetch(
`${process.env.MAILOO_API_URL}/api/v1/webhooks/forms/${process.env.PROJECT_UID}/${process.env.INTEGRATION_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.MAILOO_API_KEY,
Origin: process.env.WEBSITE_ORIGIN || '',
},
body: JSON.stringify({
email: email.trim(),
name: name?.trim() || undefined,
source: req.headers.referer || 'server-side-form',
}),
}
)
const result = await response.json()
return res.status(response.status).json(result)
})
Environment variables (example):
MAILOO_API_URL=https://api.mailoo.app
PROJECT_UID=your-project-uid
INTEGRATION_ID=your-form-integration-id
MAILOO_API_KEY=your-api-key
WEBSITE_ORIGIN=https://yourdomain.com
For Next.js, prefer @mailoo/forms --- see website-forms-nextjs-example{.interpreted-text role="doc"}.
Browser form (posts to your backend only)
Minimal HTML fields: email (required), name (optional). JavaScript should POST JSON to your same-origin route (for example /api/subscribe), never to Mailoo with a live key.
Outbound SMTP and welcome mail
Optional welcome or reply mail uses outbound SMTP on the integration. There is no platform SMTP fallback. See outbound-smtp{.interpreted-text role="doc"} and transactional-email{.interpreted-text role="doc"}.
Subscribers and campaigns (dashboard only)
Subscribers
: Listed in the dashboard (email, name, dates). Per row: Unsubscribe, Delete, or Restore for unsubscribed addresses. External sites cannot read the subscriber list via API; they only submit via the form webhook.
Campaigns as templates
: Campaigns pick an Email template (blocks + Liquid slots) from the integration Email templates tab. Campaign form fields are generated from slots bound to campaign. Every campaign stores emailTemplateId (required); subject / body on the campaign row are a compile snapshot for preview. An On-Event campaign with trigger On create (SUBSCRIBER_CREATED) is used for welcome mail and for dashboard reply preview. The form does not need a campaign to accept submissions.
Email templates tab
: On the FORM integration detail page (?view=templates): edit reusable HTML blocks, assemble them into a template, and bind each Liquid output ({{ firstName }}, {{ headline }}, ...) to a campaign field or an event field. Rendering uses LiquidJS (not Mustache).
Localization (BLOG-style)
: Blocks, templates, and campaign slot values use canonical English fields plus optional locales JSON overlays (e.g. { "ru": { "html": "β¦" } }). Do not store en in the map. Liquid slot keys stay language-neutral. Pass optional locale on form / feedback / transactional / lifecycle requests; omit or missing overlay falls back to canonical and is logged. Welcome and bulk campaign sends use Subscriber.locale when set. On the email templates tab, each locale card shows a live preview of the overlay (or canonical fallback when the overlay field is empty).
Unsubscribe
-
Transactional ``{{unsubLink}}`` --- preferred when sending via
transactional-email{.interpreted-text role="doc"}. -
One-click page ---
https://mailoo.app/{locale}/unsubscribe/confirmwith signed query parameters. -
API (server, with API key):
POST /api/v1/webhooks/unsubscribe Content-Type: application/json X-API-Key: your-api-key {"email": "user@example.com", "integrationId": "your-integration-id"}
Campaigns (dashboard)
Campaigns live under the form integration (Campaigns tab). Create ONE_TIME, ON_EVENT, or RECURRING campaigns from an Email template, fill dynamic campaign slot fields, then use Review to inspect settings, the live active subscriber list, a per-subscriber compiled preview, and optional Test sending (one address through the same campaign sender). Review does not save the audience.
Email template shell. Each template stores optional shellWidthPx (column width, default 600, range 320--800) and shellBackground (page background hex, default #f4f4f5). Blocks are HTML fragments; Mailoo wraps them in this shell when compiling for send and dashboard preview.
On-Event welcome after a new form subscription can send via the integration's outbound SMTP when the campaign status is ``SCHEDULED`` (new campaigns start as DRAFT). Identified-user registration and activation use lifecycle events (user.registered / user.activated) --- see lifecycle-events{.interpreted-text role="doc"} (same SCHEDULED rule for USER_REGISTERED / USER_ACTIVATED).
ONE_TIME bulk send. Configure Campaign send pacing on the integration Setup tab (config.campaignSend.delayBetweenEmailsMs, integer 0...600000; required --- no default). Optionally set Recipient filters on the campaign (e.g. registered before a date) --- stored as recipientFilter and applied when Review lists the audience and when copying subscribers for send. Then Send Now calls POST β¦/campaigns/{id}/send. If the campaign has no CampaignRecipient rows yet, the API copies the current active subscribers that match the campaign filter (unsubscribedAt null, plus any recipientFilter rules) into the campaign, then sets sendRequestedAt. The campaign send worker acquires a PostgreSQL advisory lock, sets status SENDING, waits a short post-claim delay, then sends pending recipients sequentially with the configured pause. Multiple API pods cannot double-send the same campaign. On failure the campaign becomes FAILED with a full lastError dump; use Retry send (POST β¦/retry) to remap only failed recipients (successful ones are never resent). Do not PATCH status=SENDING --- that returns 400. Test sending (POST β¦/test-send) uses the same sender for one live subscriber and does not queue the campaign or persist the audience. RECURRING send is not wired yet.
After a send, the Campaigns tab shows aggregate recipient counts (pending, sent, failed, delivered, opened, clicked). Open that summary (or Recipients) to see a per-address table with status timestamps and errors for the campaign.
Troubleshooting
401 --- Invalid API key
: Check the key, project UID, and that the key is active. RESTRICTED keys need webhook.form-submission.
403 --- Origin not allowed
: Add the submitting origin to Allowed origins, or clear the list if you do not need Origin checks.
429 --- Rate limited
: Back off and retry; queue on your server for high traffic.
Validation errors
: Only email is required. Optional name, subject, content, locale (visitor UI locale for welcome template overlays). Do not send a message field expecting it to become the inbox body---use content.
Security
- Keep API keys on the server; rotate regularly.
- Validate email on client and server; use HTTPS.
- Provide clear opt-in and unsubscribe paths.
Next steps
website-forms-nextjs-example{.interpreted-text role="doc"} ---@mailoo/formsBFFlifecycle-events{.interpreted-text role="doc"} --- app registration / activation eventsoutbound-smtp{.interpreted-text role="doc"} /transactional-email{.interpreted-text role="doc"}contact-feedback-form{.interpreted-text role="doc"} --- CONTACT_FORM for support messages- OpenAPI:
https://api.mailoo.app/docs/v1