Mailfully logo

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.

StatusRepresentative type
400invalid_idempotency_key
401missing_api_key, invalid_api_key, invalid_session_token
402payment_past_due
403insufficient_scope, forbidden, unverified_domain_required, account_deleted, invalid_api_key
404not_found
409conflict, invalid_idempotent_request, concurrent_idempotent_requests
422validation_error
429rate_limit_exceeded, daily_quota_exceeded, account_under_review, volume_limit_reached, monthly_quota_exceeded, marketing_quota_exceeded, spend_cap_exceeded
500internal_server_error
503service_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.