---
title: API overview
description: Base URL, authentication, error format, idempotency, rate limits, and pagination for the Mailfully API.
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

The Mailfully API is a REST API for sending transactional email and managing sending domains, templates, webhooks, and suppressions. All requests and responses are JSON over HTTPS.

## Base URL

Every route you call lives under the `/v1` path prefix. Versioning is path-only: there is no version header.

```text
https://api.mailfully.com
```

## Authentication

Authenticate your requests with a bearer token in the `Authorization` header. A token starting with `mf_` is an API key; any other bearer token is a dashboard session token.

```http
Authorization: Bearer mf_live_xxxxxxxxxxxx
```

API keys use the format `mf_<env>_<random>`, where `<env>` is `live` or `test`. Your API key's environment separates message data. A `test` key never sees `live` messages, events, or analytics, and vice versa. Templates, domains, webhooks, and suppressions are org-wide: both environments share them.

Your access is scoped. There are seven scopes total: you can mint four onto an API key (`send`, `read:emails`, `read:analytics`, `read:suppressions`); the other three are session-only and can never be granted to an API key (`manage:webhooks`, `manage:suppressions`, `manage:keys`). Call operations that require a `manage:*` scope with a dashboard session token (an API key gets `403` every time).

See [Authentication](/concepts/authentication) for the full scope table and dashboard-session details.

## Content type

Send your request bodies as `application/json` and expect `application/json` responses. Set `Content-Type: application/json` on every request with a body.

## Errors

Every error response you receive uses the same envelope:

```json
{
  "error": {
    "type": "validation_error",
    "message": "The request was invalid.",
    "param": "to"
  }
}
```

`param` is present only on validation errors and names the offending field. For batch sends, the index is prefixed onto the field, for example `[37].to`.

| Status | Representative `type` |
| --- | --- |
| 400 | `invalid_idempotency_key` |
| 401 | `missing_api_key`, `invalid_api_key`, `invalid_session_token` |
| 403 | `insufficient_scope`, `forbidden`, `unverified_domain_required`, `invalid_api_key` |
| 404 | `not_found` |
| 409 | `conflict`, `invalid_idempotent_request`, `concurrent_idempotent_requests` |
| 422 | `validation_error` |
| 429 | `rate_limit_exceeded`, `daily_quota_exceeded`, `monthly_quota_exceeded`, `spend_cap_exceeded` |
| 500 | `internal_server_error` |
| 503 | `service_unavailable` |

Validation failures return `422`, not `400`.

See [Errors](/concepts/errors) for the full catalog of error types and messages.

## Idempotency

Send an `Idempotency-Key` header on `POST /v1/emails` or `POST /v1/emails/batch` to make your retries safe:

```http
Idempotency-Key: order-8412-shipped
```

A repeated key with the same request body replays the original response for 24 hours; the same key with a different body returns `409`. Idempotency is opt-in — no header means no deduplication. It is honored only on those two endpoints.

See [Idempotency](/concepts/idempotency) for replay semantics and edge cases.

## Rate limits

Rate limiting applies to `POST /v1/emails` and `POST /v1/emails/batch`. Your organization gets a token bucket with a burst of 10 requests and a steady refill of 8 requests per second.

Every allowed request on those endpoints carries:

```text
ratelimit-limit: 10
ratelimit-remaining: 7
ratelimit-reset: 0
```

If you're rate-limited, you get `429 rate_limit_exceeded` with a `retry-after` header (seconds) alongside the `ratelimit-*` headers.

See [Rate limits](/concepts/rate-limits) for bucket details and backoff guidance.

## Pagination

Four lists paginate: `GET /v1/emails`, `GET /v1/suppressions`, `GET /v1/webhooks/{id}/deliveries`, and `GET /v1/webhook-dead-letters`. They take `limit` and `cursor` query parameters and return a `data` array with `has_more` and `next_cursor`. `limit` defaults to 25 (max 100); webhook deliveries and dead-letter lists default to 50 (also max 100). `cursor` is opaque. Pass back the previous response's `next_cursor` verbatim; never construct your own.

The short lists (API keys, webhooks, templates, domains) are not paginated: they return everything in `data` with no cursor fields.

```json
{
  "data": [
    { "id": "01JZFYK2N8Q4T7W9E1R3M5X0AB", "...": "..." }
  ],
  "has_more": true,
  "next_cursor": "eyJ0cyI6IjIwMjYtMDctMDggMTQ6MDI6MTEiLCJpZCI6IjAxSlpGWUsyTjhRNFQ3VzlFMVIzTTVYMEFCIn0"
}
```

When `has_more` is `false`, `next_cursor` is `null`.
