API overview
Base URL, authentication, error format, idempotency, rate limits, and pagination for the Mailfully API.
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. Set Content-Type: application/json on every request with a body.
Base URL
Every route you call lives under the /v1 path prefix. Versioning is path-only: there is no version header.
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.
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 eight scopes: you can give an API key five of them (send, read:emails, read:inbound, read:analytics, read:suppressions). The other three (manage:webhooks, manage:suppressions, manage:keys) are dashboard only. API keys get 403 insufficient_scope on those operations.
See Authentication for the full scope table and dashboard-session details.
Errors
Every error response you receive uses the same envelope:
{
"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 |
| 402 | payment_past_due |
| 403 | insufficient_scope, forbidden, unverified_domain_required, account_deleted, invalid_api_key |
| 404 | not_found |
| 409 | conflict, invalid_idempotent_request, concurrent_idempotent_requests |
| 422 | validation_error |
| 429 | rate_limit_exceeded, daily_quota_exceeded, account_under_review, volume_limit_reached, monthly_quota_exceeded, marketing_quota_exceeded, spend_cap_exceeded |
| 500 | internal_server_error |
| 503 | service_unavailable |
See 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:
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 for replay semantics and edge cases.
Rate limits
Rate limiting applies to POST /v1/emails and POST /v1/emails/batch. Each organization gets a token bucket per environment, with a burst of 10 requests and a steady refill of 8 requests per second.
Every allowed request on those endpoints carries:
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.
Adding and deleting domains share a separate limit of 10 a minute per environment.
See Rate limits for 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.
GET /v1/emails/receiving pages differently: it returns next_cursor and prev_cursor, and takes them back as after (older mail) and before (newer mail) instead of cursor.
The short lists (API keys, webhooks, templates, domains) are not paginated: they return everything in data with no cursor fields.
{
"data": [
{ "id": "01JZFYK2N8Q4T7W9E1R3M5X0AB", "...": "..." }
],
"has_more": true,
"next_cursor": "eyJ0cyI6IjIwMjYtMDctMDggMTQ6MDI6MTEiLCJpZCI6IjAxSlpGWUsyTjhRNFQ3VzlFMVIzTTVYMEFCIn0"
}
When has_more is false, next_cursor is null.
