Overview
Femail is an HTTP API for delivering opt-in email. Authenticate with an API key, POST a JSON payload to /api/send/transactional, and Femail queues the message, hands it to the configured email provider (Amazon SES or Resend, depending on the account's settings), and records delivery events.
- Base URL
https://thexitgroup.com- Auth
X-API-Key- Content type
application/json- Rate limit
- Per-account daily send cap; default 1,000/day
- Suppression
- Bounced/complained/unsubscribed addresses are blocked automatically
Authentication
Send the API key in an X-API-Key header on every request. Generate keys on the API keys page. The full secret is shown exactly once at creation; only a SHA-256 hash is stored. Treat the key like a password — keep it server-side and rotate if leaked.
curl https://thexitgroup.com/api/send/transactional \
-H "X-API-Key: femail_XXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{"fromEmail":"hello@example.com","to":"you@example.com","subject":"Hi","html":"<p>Hi</p>","text":"Hi"}'Each key is pinned to a single Femail account; account-level verified domains, suppression list, and daily send cap all apply. Keys carry per-key controls in addition:
| Control | What it does | Failure response |
|---|---|---|
scopes | Restrict which endpoints the key can call. Values: send (for POST /api/send/transactional), read_events (for GET /api/messages, filtered to messages the key itself sent), contacts:read (for GET /api/contacts), and contacts:write (for POST /api/contacts and POST /api/contacts/import). | 403 missing required scope |
expiresAt | Optional UTC date/time after which the key is rejected. | 401 API key expired |
ipAllowlist | Optional list of exact source IPv4/IPv6 addresses. Checked against the first hop of X-Forwarded-For. CIDR ranges aren't supported yet. | 401 denied for this client IP |
dailyLimit | Optional per-key cap on messages sent per UTC calendar day. Lower than the account-wide cap when set. Counted from MessageLog.apiKeyId. | 400 Daily send limit reached for this API key |
| revoke | Click Revoke in the dashboard or DELETE /api/api-keys/:id via the admin API. Effective immediately. | 401 Invalid API key |
POST /api/send/transactional
Queue a single transactional message for delivery.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| to | string (email) | yes | Recipient address. Must not be on the account's suppression list. |
| fromEmail | string (email) | yes | Must resolve to a verified domain or sender identity on this account. |
| fromName | string | no | Display name shown before the email in the From header. |
| replyTo | string (email) | no | Reply-To header. Useful when sending from no-reply@yourdomain and routing replies to a real inbox. |
| subject | string | conditional | Required unless templateId is provided. |
| html | string | conditional | HTML body. Required unless templateId is provided. |
| text | string | conditional | Plain-text fallback. Required unless templateId is provided. |
| templateId | string (cuid) | no | Use a saved EmailTemplate. When set, subject/html/text are sourced from the template (overridable). |
| variables | object | no | Key/value pairs used to render Mustache-style placeholders in the template (e.g. {{firstName}}). |
Response · 201 Created
{
"queued": true,
"messageLogId": "cmpk4fujv0002mp01gyfuife0"
}The messageLogId is the internal ID of the MessageLog row. Webhook events and the /messages page key off it. The provider returns its own ID asynchronously; it's stored on the same row as providerMessageId once the send succeeds.
Errors
Femail returns conventional HTTP status codes. The body is JSON with a message field describing the problem.
| Status | When | Sample message |
|---|---|---|
| 400 Bad Request | Invalid payload, suppressed recipient, unverified sender, per-key cap hit | "hello@unknown.com is not verified for sending" |
| 401 Unauthorized | Missing/invalid key, revoked key, expired key, disallowed IP | "API key denied for this client IP" |
| 403 Forbidden | Key is valid but doesn't carry the scope required by the endpoint | "API key is missing required scope: read_events" |
| 429 Too Many Requests | Account daily send limit exceeded | "Daily send limit reached" |
| 500 Internal Server Error | Provider outage or unexpected failure | "Provider error: …" |
{
"message": "support@example.com is suppressed",
"error": "Bad Request",
"statusCode": 400
}Examples
curl
curl https://thexitgroup.com/api/send/transactional \
-H "X-API-Key: $FEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fromEmail": "support@wellny.org",
"fromName": "Wellny Support",
"to": "customer@example.com",
"subject": "Your order has shipped",
"html": "<p>Thanks for your order. Track it <a href=\"https://example.com/track\">here</a>.</p>",
"text": "Thanks for your order. Track it at https://example.com/track"
}'Node.js (fetch)
const response = await fetch("https://thexitgroup.com/api/send/transactional", {
method: "POST",
headers: {
"X-API-Key": process.env.FEMAIL_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
fromEmail: "support@wellny.org",
fromName: "Wellny Support",
to: "customer@example.com",
subject: "Your order has shipped",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order."
})
});
if (!response.ok) throw new Error(await response.text());
const { messageLogId } = await response.json();Python (requests)
import os, requests
r = requests.post(
"https://thexitgroup.com/api/send/transactional",
headers={"X-API-Key": os.environ["FEMAIL_API_KEY"]},
json={
"fromEmail": "support@wellny.org",
"fromName": "Wellny Support",
"to": "customer@example.com",
"subject": "Your order has shipped",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
},
timeout=10,
)
r.raise_for_status()
message_log_id = r.json()["messageLogId"]Templated send
curl https://thexitgroup.com/api/send/transactional \
-H "X-API-Key: $FEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fromEmail": "hello@wellny.org",
"to": "ada@example.com",
"templateId": "cmpk4fujv0002mp01gyfuife0",
"variables": { "firstName": "Ada", "orderNumber": "1042" }
}'POST /api/send/sms (sms:send scope)
Sends one SMS through AWS End User Messaging. The account must have SMS enabled and an origination phone number provisioned (admin sets this from Settings → SMS).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | yes | E.164 (+17745551234). 10-digit US accepted. |
body | string | yes | SMS body. Max 10 segments (≈1,600 chars GSM-7 / 670 UCS-2). |
Success (201)
{
"queued": true,
"messageLogId": "cmpk4fujv0002mp01gyfuife0",
"segments": 1,
"encoding": "gsm7"
}Common errors
400— invalid phone, opt-out, sandbox restriction, no origination number, body too long401— invalid / expired / revoked key403— missingsms:sendscope429— account daily send limit reached
curl
curl https://thexitgroup.com/api/send/sms \
-H "X-API-Key: $FEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+17745551234", "body": "Hi from Femail" }'Opt-outs
STOP, UNSUBSCRIBE, and CANCEL keywords are handled by AWS automatically. Numbers that opt out are pulled into the Femail suppression list by a background sync every 15 minutes (or call POST /api/sms/sync-optouts from an admin session to force a sync). Once a number is opted out, future sends return 400.
Compliance and limits
- Opt-in only. Femail does not allow sending to addresses that haven't consented. Bounced, complained, and unsubscribed addresses are added to an account-wide suppression list automatically and will return
400on subsequent sends. - Sender verification. The
fromEmailmust belong to a verified domain (full domain DKIM + SPF + MAIL FROM verified) or a single sender identity. Add domains on the Domains page. - Unsubscribe headers. Campaign sends always include
List-UnsubscribeandList-Unsubscribe-Postheaders. For transactional sends, passunsubscribeUrlif you want those headers included. - Tracking. When the account uses Femail-native tracking, outbound HTML is rewritten so
<a href>URLs go throughhttps://thexitgroup.com/r/<messageId>and a 1×1 pixel is injected. Provider-side tracking is disabled to avoid double-wrapping. - Rate limits. Each Femail account has a configurable daily send cap (default 1,000). Exceeding it returns
429.
GET /api/messages (read_events scope)
A key that includes the read_events scope can poll its own send history and per-message event timeline. The endpoint is also reachable via dashboard JWT for full account-wide access.
GET /api/messages— paginated. Query params:?status=,?search=,?from=,?to=,?cursor=,?limit=(default 25, max 100).GET /api/messages/:id— full message + sortedEmailEventtimeline (sent, delivered, bounced, complained, opened, clicked).GET /api/messages/aggregate— one-shot stats for a campaign or filtered set. Same filters as the list endpoint plussubjectandcampaignId. Returns{ total, status: {...}, opens: { unique, total }, clicks: { unique, total } }. Use this for broadcast/campaign dashboards instead of paginating.- Least-privilege filter: when authenticated via API key, results are automatically scoped to
WHERE apiKeyId = <this key>— a key can only read messages it itself sent, not other API keys' or dashboard sends. The detail endpoint also omits the joinedcontactandcampaignrows for API-key callers, so a key never seesfirstName/lastNamethat weren't in the original send payload.
curl https://thexitgroup.com/api/messages?limit=10 \
-H "X-API-Key: $FEMAIL_API_KEY"Contacts (contacts:read / contacts:write)
Sync contacts from your own system (Shopify, CRM, custom backend) into Femail. The same endpoints power the dashboard's import flow.
GET /api/contacts?page=&limit=&search=&status=&tag=— paginated read. Returns{ items, total, page, pageSize, totalPages }. Requirescontacts:read.POST /api/contacts— upsert one contact byemail. Body:{ email, firstName?, lastName?, company?, tags?, status?, listIds? }. Requirescontacts:write.POST /api/contacts/import— bulk upsert. Body:{ contacts: [...], listId? }. Idempotent (upsert byemail). Use batches of ≤500. Requirescontacts:write.
# Sync one contact (opt-in, please)
curl https://thexitgroup.com/api/contacts \
-H "X-API-Key: $FEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"company": "Analytical Engine Co",
"tags": ["vip", "founder"]
}'Contacts written via API must be opt-in. Femail does not enforce a marketing-consent flag at the schema level, so the caller is responsible for filtering to subscribers who consented in your source system before pushing them in.
Outbound webhooks
Configure HTTP endpoints in your own system that Femail will POST to whenever a contact unsubscribes, bounces, complains, or is otherwise suppressed. Manage at /webhooks.
Events
| Event | When it fires |
|---|---|
| contact.unsubscribed | Recipient hit a Femail unsubscribe link (one-click or public form). |
| contact.bounced | Provider reported a hard bounce; recipient is now suppressed. |
| contact.complained | Recipient marked your mail as spam; recipient is now suppressed. |
| contact.suppressed | Manual or other automatic suppression (anything not bounce/complaint). |
Payload
{
"event": "contact.unsubscribed",
"occurredAt": "2026-05-24T18:45:12.328Z",
"accountId": "cmpfsg9h50008lr01ri4cje07",
"data": {
"email": "ada@example.com",
"source": "one_click",
"contactId": "cmpk...",
"firstName": "Ada",
"lastName": "Lovelace",
"campaignId": null
}
}Headers
- X-Femail-Event
contact.unsubscribed- X-Femail-Delivery
- unique cuid per delivery attempt
- X-Femail-Timestamp
- unix seconds at signing time
- X-Femail-Signature
v1=<base64 hmac>- X-Femail-Attempt
- 1, 2, 3, …
Verifying the signature
HMAC-SHA256 over `${timestamp}.${rawBody}` using the signing secret shown in the dashboard. Same construction as Stripe.
import crypto from "node:crypto";
function verify(req, secret) {
const sig = req.header("X-Femail-Signature")?.split("=")[1] || "";
const ts = req.header("X-Femail-Timestamp") || "";
// Reject anything older than 5 minutes.
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`)
.digest("base64");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Retries
- 2xx → delivered.
- 408 / 429 / 5xx / network error / timeout (15s) → exponential backoff, up to 6 attempts (1m → 32m total). After that the delivery is marked failed and visible in the dashboard for 14 days.
- Other 4xx → permanent failure, no retry. Fix your endpoint and use the Test button to re-verify.
Inbound webhooks (provider events)
Delivery, bounce, complaint, open, and click events flow into Femail from the underlying provider (SES via SNS or Resend via signed Svix POSTs) and are persisted as EmailEvent rows linked to the MessageLog. There is no outbound webhook from Femail to third parties yet — poll GET /api/messages with a read_events-scoped key, or browse via /messages in the dashboard.