# SendFox Transactional Email API

Send one-to-one email from your app: login codes, password resets, receipts, notifications. HTTPS + JSON. Works on every SendFox plan, including free.

- Base URL: `https://api.sendfox.com`
- Auth: `Authorization: Bearer <API key>`. Create the key on the Transactional page of your SendFox account (https://synapsebytes.sellersynapse.com/transactional).
- Free: 3,000 emails a month, 100 a day.
- Paid: $15 a month for 50,000 emails, no daily cap. Optional overage at $0.50 per 1,000, capped (default $50).
- Every address in `to`, `cc` and `bcc` counts as one email. Up to 50 addresses in `to`, plus 50 more across `cc` and `bcc`.
- Rate limit: 5 requests a second, 300 a minute, per key.
- Not supported yet: attachments, scheduled sends, SMTP, inbound email. There is no attachment field; the Resend-compatible endpoint refuses `attachments` and `scheduled_at` with a 422.
- Full OpenAPI reference: https://sendfox.com/developer/docs/#tag/Transactional (spec: https://synapsebytes.sellersynapse.com/openapi.yaml)

## Send an email

`POST /transactional/emails`

| Field | Type | Notes |
|---|---|---|
| `from` | string | Required. `hello@mail.acme.com` or `Acme <hello@mail.acme.com>`. The domain must be verified, or use the sandbox sender `onboarding@sendfoxmail.com`. |
| `to` | string[] | Required. 1 to 50 addresses. |
| `cc`, `bcc` | string[] | Optional. Together at most 50. |
| `reply_to` | string | Optional. |
| `subject` | string | Required. |
| `html` | string | HTML body, up to 500 KB. Send `html`, `text` or both. |
| `text` | string | Plain-text body, up to 100 KB. |
| `tag` | string | Optional. Lowercase kind of email, e.g. `login_code`, `receipt`. Used for grouping, per-tag tracking and alerts. |
| `headers` | object | Optional. Up to 20 `List-*`, threading or `X-*` headers. |

Optional header `Idempotency-Key` (1 to 256 chars): the same key and body within 24 hours returns the original message instead of sending again.

Response `202`:

```json
{ "id": "tm_4f0c9b2e7d1a5c3e8b6f2a9d", "status": "queued" }
```

Every response carries `x-sendfox-monthly-quota-remaining` (and `x-sendfox-daily-quota-remaining` on the free plan) and `x-request-id`.


### curl

```bash
curl https://api.sendfox.com/transactional/emails \
  -H "Authorization: Bearer $SENDFOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: login-code-8f2c1" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": ["ada@example.com"],
    "subject": "Your login code",
    "html": "<p>Your code is <strong>483 920</strong></p>",
    "text": "Your code is 483 920",
    "tag": "login_code"
  }'
```

### Node.js / TypeScript (fetch, no SDK needed)

```ts
const res = await fetch('https://api.sendfox.com/transactional/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDFOX_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 'Acme <hello@mail.acme.com>',
    to: ['ada@example.com'],
    subject: 'Your login code',
    html: '<p>Your code is <strong>483 920</strong></p>',
    tag: 'login_code',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.name}: ${body.fix}`);
console.log(body.id); // tm_...
```

### Python (requests)

```python
import os
import requests

response = requests.post(
    "https://api.sendfox.com/transactional/emails",
    headers={"Authorization": f"Bearer {os.environ['SENDFOX_API_KEY']}"},
    json={
        "from": "Acme <hello@mail.acme.com>",
        "to": ["ada@example.com"],
        "subject": "Your login code",
        "html": "<p>Your code is <strong>483 920</strong></p>",
        "tag": "login_code",
    },
    timeout=10,
)
body = response.json()
if not response.ok:
    raise RuntimeError(f"{body['name']}: {body['fix']}")
print(body["id"])  # tm_...
```

### PHP (Guzzle or any HTTP client)

```php
$response = (new GuzzleHttp\Client())->post('https://api.sendfox.com/transactional/emails', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('SENDFOX_API_KEY')],
    'json' => [
        'from' => 'Acme <hello@mail.acme.com>',
        'to' => ['ada@example.com'],
        'subject' => 'Your login code',
        'html' => '<p>Your code is <strong>483 920</strong></p>',
        'tag' => 'login_code',
    ],
    'http_errors' => false,
]);
$body = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 300) {
    throw new RuntimeException($body['name'] . ': ' . $body['fix']);
}
echo $body['id']; // tm_...
```


## Send a batch

`POST /transactional/emails/batch` with `{ "emails": [ ... ] }`: 1 to 100 items, each with the same fields as a single send plus an optional `idempotency_key`. The response lists a result per item, in order.

## Look up an email

- `GET /transactional/emails/{id}`: the message, its status (`queued`, `sent`, `delivered`, `bounced`, `complained`, `failed`, `suppressed`) and its event timeline.
- `GET /transactional/emails`: recent messages, newest first.

Logs are kept 30 days.

## Test without sending real mail

- Sandbox sender `onboarding@sendfoxmail.com` needs no DNS and delivers to your own account email and the test addresses.
- Test addresses never reach a mailbox and never count against your quota: `delivered@sendfoxmail.com`, `bounced@sendfoxmail.com`, `complained@sendfoxmail.com`, `suppressed@sendfoxmail.com`. Each fakes that outcome, webhook included.
- To send from your own domain, add it under Account > Domains and add the DNS records shown.

## Errors

Every non-2xx response has the same shape:

```json
{ "status": 403, "name": "domain_not_verified", "message": "...", "fix": "...", "link": "...", "request_id": "..." }
```

| Status | name | When |
|---|---|---|
| 401 | `missing_api_key` | No Authorization header |
| 401 | `invalid_api_key` | The key is wrong or revoked |
| 403 | `insufficient_scope` | The key lacks the needed transactional scope |
| 403 | `feature_not_available` | Transactional email is not enabled on the account |
| 403 | `domain_not_verified` | The from domain is not verified |
| 403 | `sandbox_recipient_not_allowed` | Sandbox sender to an address that is not yours |
| 403 | `recipient_suppressed` | A recipient is on your suppression list |
| 403 | `account_paused` | Sending is paused for review |
| 404 | `not_found` | No message with that id |
| 409 | `invalid_idempotent_request` | Same Idempotency-Key, different body, within 24 hours |
| 422 | `validation_error` | Field errors in `errors` |
| 429 | `rate_limit_exceeded` | Over the rate limit; wait `Retry-After` seconds |
| 429 | `daily_quota_exceeded` | Free plan daily limit used; resets at midnight UTC |
| 429 | `monthly_quota_exceeded` | Monthly limit used; upgrade or turn on overage |

## Webhooks

Add one https URL under Transactional > Webhooks. SendFox POSTs a JSON event for `sent`, `delivered`, `delayed`, `bounced`, `complained` and `failed`. Any 2xx counts as received; anything else is retried after 1 minute, 15 minutes, 2 hours and 18 hours with the same `id`, so dedupe on it.


```json
{
  "id": "whd_5f0c2a9e1b7d4c3a8e6f1d2b",
  "event": "delivered",
  "occurred_at": "2026-10-10T14:03:22+00:00",
  "data": {
    "message": { "id": "tm_...", "status": "delivered", "to": ["ada@example.com"], "subject": "Your code", "tag": "login_code" },
    "recipient": "ada@example.com",
    "status": null,
    "reason": null
  }
}
```

Verify every request. `X-SendFox-Signature` is `t={unix seconds},v1={hex}`, where `hex` is HMAC-SHA256 of `{t}.{raw body}` keyed with your signing secret (the whole `whsec_...` string):

```ts
import crypto from 'node:crypto';

export function verifySendFoxSignature(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return expected.length === parts.v1?.length
    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```


Switching from Resend? Set the webhook format to Resend and events arrive as `email.sent`, `email.delivered`, `email.bounced` and so on, with `svix-*` signature headers, so an existing Resend handler keeps working with the SendFox secret.

## Already using the Resend SDK

SendFox accepts Resend's request shape at `/transactional/resend`. Change two environment variables and keep your code:

```
RESEND_BASE_URL=https://api.sendfox.com/transactional/resend
RESEND_API_KEY=<your SendFox key>
```

`resend.emails.send()`, `resend.batch.send()` and `resend.emails.get()` keep working. Message ids start with `tm_`.

## Framework guides

- Next.js: https://synapsebytes.sellersynapse.com/guides/send-email-from-nextjs
- Laravel: https://synapsebytes.sellersynapse.com/guides/laravel-transactional-email
- Django password reset: https://synapsebytes.sellersynapse.com/guides/django-password-reset-email
- Supabase auth emails: https://synapsebytes.sellersynapse.com/guides/supabase-auth-emails
- Switch from Resend: https://synapsebytes.sellersynapse.com/guides/switch-from-resend

## AI assistants

The SendFox MCP server (https://synapsebytes.sellersynapse.com/developer/mcp) can send transactional email, look up a message and list recent ones, so Claude, Cursor and other assistants can send and debug for you.

Product page and pricing: https://synapsebytes.sellersynapse.com/email-api
