# Femail > Femail is a transactional email API. Authenticated callers POST a JSON payload to > https://thexitgroup.com/api/send/transactional and Femail queues the message, > hands it to the configured provider (Amazon SES or Resend), and records every delivery > event (sent, delivered, bounced, complained, opened, clicked) in a per-message log. Base URL: https://thexitgroup.com Docs: https://thexitgroup.com/docs/api Console: https://thexitgroup.com/dashboard ## Authentication - Header: `X-API-Key: femail_` - Get a key at https://thexitgroup.com/api-keys - Keys are pinned to a single Femail customer account; the `X-Femail-Account` header has no effect on API-key auth. - Plain JWT (Bearer) auth also works for admin/console operations but is not recommended for programmatic sends. ### Per-key controls Every API key carries optional restrictions evaluated on every request: | Field | Purpose | Failure response | |---------------|----------------------------------------------------------------|--------------------------------------------------------| | `scopes` | Limits which endpoints the key can call. Values: `send`, `read_events`, `contacts:read`, `contacts:write`. | 403 `"API key is missing required scope: "` | | `expiresAt` | UTC timestamp after which the key stops working. | 401 `"API key expired"` | | `ipAllowlist` | Exact source IPv4/IPv6 list, matched against first `X-Forwarded-For` hop. CIDR not supported. | 401 `"API key denied for this client IP"` | | `dailyLimit` | Per-key UTC-day cap on messages, in addition to account cap. | 400 `"Daily send limit reached for this API key (N)"` | | revoked | Set by `DELETE /api/api-keys/:id` or the Revoke button. | 401 `"Invalid API key"` | Defaults: no expiry, no IP restriction, no per-key cap, scopes = `["send"]`. ## Endpoint: POST /api/send/transactional (`send` scope) Sends one email. Request body (application/json): | Field | Type | Required | Notes | |--------------|------------------|-------------|--------------------------------------------------------------------| | `to` | string (email) | yes | Recipient. Suppressed addresses return 400. | | `fromEmail` | string (email) | yes | Must belong to a verified domain or sender on this account. | | `fromName` | string | no | Display name in the From header. | | `replyTo` | string (email) | no | Reply-To header. Use this when sending from no-reply@… | | `subject` | string | conditional | Required unless `templateId` is provided. | | `html` | string | conditional | Required unless `templateId` is provided. | | `text` | string | conditional | Required unless `templateId` is provided. | | `templateId` | string (cuid) | no | Use a saved template; subject/html/text inherited from it. | | `variables` | object | no | Mustache-style placeholder values (`{{firstName}}` etc.) | Success response (201 Created): ```json { "queued": true, "messageLogId": "cmpk4fujv0002mp01gyfuife0" } ``` Errors return: ```json { "message": "...", "error": "Bad Request", "statusCode": 400 } ``` Common status codes: - 400 — invalid payload, suppressed recipient, unverified sender, per-key cap reached - 401 — missing/invalid/expired/revoked key, or disallowed source IP - 403 — key is valid but missing the scope required by the endpoint - 429 — account daily send limit reached (default 1,000/day) - 500 — provider failure ## Endpoint: GET /api/messages (`read_events` scope) Read this key's send history and per-message event timelines. Available endpoints: - `GET /api/messages?status=&search=&from=&to=&cursor=&limit=` — paginated list - default `limit` = 25, max = 100 - returns `{ items, nextCursor, total }` - `GET /api/messages/:id` — full message with sorted `EmailEvent` array - `GET /api/messages/aggregate?status=&subject=&campaignId=&search=&from=&to=` — one-shot aggregate counts across the entire matched set (no pagination). Use this for campaign/broadcast dashboards. Returns: ```json { "total": 1479, "status": { "queued": 0, "sent": 0, "delivered": 1410, "bounced": 65, "complained": 0, "rejected": 0, "failed": 4 }, "opens": { "unique": 102, "total": 125 }, "clicks": { "unique": 36, "total": 152 } } ``` Least-privilege filter: when authenticated via API key, results are scoped to `WHERE apiKeyId = `. A key can only see messages it itself sent. Dashboard sends, other keys' sends, and campaign worker sends are invisible. The detail endpoint omits the joined `contact` and `campaign` rows for API-key callers — a key never sees contact fields (firstName, lastName) that the original send call didn't supply. ```bash curl https://thexitgroup.com/api/messages?limit=10 \ -H "X-API-Key: $FEMAIL_API_KEY" ``` ## Endpoint: POST /api/send/sms (`sms:send` scope) Sends one SMS through AWS End User Messaging. The account must have `smsEnabled=true` and an `smsOriginationIdentityArn` (or `smsOriginationPhoneNumber`) provisioned by an admin. Request body (application/json): | Field | Type | Required | Notes | |-------|----------|----------|---------------------------------------------------------| | `to` | string | yes | E.164 phone (e.g. `+17745551234`). 10-digit US accepted. | | `body`| string | yes | The SMS body. Max 10 segments (≈1,600 chars GSM-7). | Success response (201 Created): ```json { "queued": true, "messageLogId": "cmpk...", "segments": 1, "encoding": "gsm7" } ``` Common status codes: - 400 — invalid phone, opt-out, sandbox restriction, no origination number on account, body too long - 401 — invalid/expired/revoked key - 403 — missing `sms:send` scope - 429 — account daily send limit reached ```bash 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" }' ``` STOP / UNSUBSCRIBE / CANCEL keywords are handled by AWS automatically. Inbound opt-outs sync to Femail every 15 minutes (or call `POST /api/sms/sync-optouts` from an admin session to sync immediately). Once a number opts out, future sends return 400. ## Endpoints: Contacts (`contacts:read` / `contacts:write`) Push contacts from your source of truth (Shopify, CRM, custom backend) into Femail. Same code path as the dashboard's import flow. - `GET /api/contacts?page=&limit=&search=&status=&tag=` — paginated read. Returns `{ items, total, page, pageSize, totalPages }`. Requires `contacts:read`. - `POST /api/contacts` — upsert one. Body: `{ email, firstName?, lastName?, company?, tags?, status?, listIds? }`. Requires `contacts:write`. Idempotent on `(accountId, email)`. - `POST /api/contacts/import` — bulk upsert. Body: `{ contacts: [...], listId? }`. Idempotent. Use batches of ≤500. Requires `contacts:write`. ```bash 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","tags":["vip"]}' ``` Caller is responsible for filtering to opt-in subscribers before pushing. Femail does not enforce a marketing-consent flag at the schema level. ## Outbound webhooks (from Femail to your system) Configure HTTP endpoints in your own backend that Femail will POST to whenever a subscribed event fires. Manage at https://thexitgroup.com/webhooks. Events: - `contact.unsubscribed` — recipient hit a Femail unsubscribe link - `contact.bounced` — provider reported a hard bounce - `contact.complained` — recipient flagged as spam - `contact.suppressed` — any other suppression (manual, etc.) Payload (`Content-Type: application/json`): ```json { "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 on every delivery: - `X-Femail-Event` — event name - `X-Femail-Delivery` — unique cuid per delivery (for idempotency) - `X-Femail-Timestamp` — unix seconds at signing time - `X-Femail-Signature` — `v1=` - `X-Femail-Attempt` — 1, 2, 3, … Verification (Node): ```javascript 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") || ""; 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), b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` Retry policy: - 2xx → delivered. - 408 / 429 / 5xx / network error / 15s timeout → exponential backoff, up to 6 attempts. - Other 4xx → permanent failure, no retry. Fix and use the dashboard's Test button. ## Minimal send example ```bash curl https://thexitgroup.com/api/send/transactional \ -H "X-API-Key: femail_XXXXXXXXXXXXXXXXXX" \ -H "Content-Type: application/json" \ -d '{ "fromEmail": "hello@example.com", "to": "ada@example.com", "subject": "Welcome", "html": "

Hi Ada

", "text": "Hi Ada" }' ``` ## Rules and constraints - Opt-in only. Femail refuses sends to suppressed addresses; bounces/complaints suppress automatically; campaigns require a `List-Unsubscribe` header (added automatically). - `fromEmail` must match a verified domain or sender identity for the account. Verify domains at https://thexitgroup.com/domains. - Sandboxes: when the account is on Amazon SES and SES production access is not yet approved, recipients must also be pre-verified in SES. - Daily send cap defaults to 1,000 per account; configurable in the database. Per-key caps are lower bounds on top of that. - Sends are queued. The response returns `messageLogId` immediately; actual delivery happens in a BullMQ worker. ## Inspecting message status Each send creates a `MessageLog` row plus a stream of `EmailEvent` rows (queued, sent, delivered, bounced, complained, opened, clicked). Three ways to read them: 1. Dashboard UI at https://thexitgroup.com/messages (admin login). 2. `GET /api/messages` with a `read_events`-scoped API key — see your own sends only. 3. `GET /api/messages` with a dashboard JWT — see every send on the account. There is no outbound webhook from Femail to third parties yet. Poll the endpoint. ## Tracking - Opens and clicks are tracked by default. Toggle per account at https://thexitgroup.com/settings. - Two tracking modes: provider-native (SES/Resend wrap links + inject pixels) or Femail-native (Femail rewrites HTML so links go through `https://thexitgroup.com/r/` and the pixel is served from `https://thexitgroup.com/p/.gif`). - Apple Mail Privacy Protection inflates open counts because it pre-fetches pixels. ## Related endpoints (admin-auth, not API key) - `GET /api/contacts` — paginated contacts (`?page=`, `?limit=`, `?search=`) - `POST /api/contacts/import` — bulk import (Shopify/Generic CSV) - `GET /api/identities/domains` — verified sending domains - `GET /api/settings` — provider + tracking flags for the current account - `GET /api/api-keys` — list keys (full schema, hash redacted) - `POST /api/api-keys` — create a new key with optional `scopes`, `expiresAt`, `dailyLimit`, `ipAllowlist` - `DELETE /api/api-keys/:id` — revoke (effective immediately) - `POST /api/webhooks/resend` — inbound from Resend (Svix-signed) - `POST /api/webhooks/ses` — inbound from SES via SNS