API Reference
API Reference
GitHub ↗
Security

Rate Limiting

Email and API rate limits

Mailyard enforces rate limits to prevent abuse and ensure fair usage.

There are two independent mechanisms: send volume limits, which come from the project’s plan, and request rate limits on the HTTP and SMTP edges.

Email Send Limits

Send volume is governed by the plan assigned to the project (hourly_email_limit, daily_email_limit, 0 meaning unlimited). Counts are read from the emails table over the trailing hour and day, so there is no counter state to drift.

Exceeding a limit returns 429 Too Many Requests on the HTTP API:

{
    "error": "hourly email limit reached (100 per hour on plan \"Free\")"
}

The SMTP relay maps the same condition to a transient 452 4.7.0, so a well-behaved client retries after the window rolls.

Each recipient in a batch send counts separately. A batch of 100 recipients counts as 100 emails.

GET /api/v1/usage reports the current limits alongside consumption.

Request Rate Limits

Fixed-window counters on the HTTP edge, configured under ratelimit:

SettingEnvDefaultDescription
ratelimit.enabledMAILYARD_RATELIMIT_ENABLEDtrueMaster switch for every limiter below.
ratelimit.login_per_minuteMAILYARD_RATELIMIT_LOGIN_PER_MINUTE10Console sign-in, keyed by client IP.
ratelimit.oidc_per_minuteMAILYARD_RATELIMIT_OIDC_PER_MINUTE30The OIDC callback, keyed by client IP.
ratelimit.api_per_minuteMAILYARD_RATELIMIT_API_PER_MINUTE120/api/v1/*, keyed by API key (falling back to client IP).
ratelimit.session_per_minuteMAILYARD_RATELIMIT_SESSION_PER_MINUTE600/api/v1/* from a signed-in console session, per session.
ratelimit.ses_webhook_per_minuteMAILYARD_RATELIMIT_SES_WEBHOOK_PER_MINUTE600POST /webhooks/ses, keyed by client IP.
ratelimit.relay_node_chatter_per_minuteMAILYARD_RATELIMIT_RELAY_NODE_CHATTER_PER_MINUTE600Relay node heartbeats, certificate renewal and status.
ratelimit.relay_node_inbound_per_minuteMAILYARD_RATELIMIT_RELAY_NODE_INBOUND_PER_MINUTE1200Mail a relay node forwards back to the platform.

Setting an individual value to 0 disables that limiter while leaving the others in place.

A refused request answers 429 with the usual error envelope and a Retry-After header giving the seconds until the window rolls:

{
    "error": "too many requests, retry after the number of seconds in Retry-After"
}

The console has a budget of its own on /api/v1 because one page is several calls and the lists refresh themselves on a timer, which a person browsing would exhaust at the integration ceiling. An API key never draws on it.

/api/v1 also counts rejected credentials per client address, at the login_per_minute ceiling. Once an address has spent it, further rejected attempts from it answer 429 instead of 401. A credential that works is never charged and never refused, so somebody guessing from a shared office address cannot lock out the integrations and people signing in from behind it.

The login limiter is per client address. The account counts its own failures too: ten wrong passwords in a row lock sign-in for that account for fifteen minutes, whatever addresses they came from, and the answer during the lockout is the same invalid credentials a wrong password gets. The lock is held against the addresses that earned it: one that has not failed a password for the account is admitted to a single attempt, and a wrong one puts it under the lock too. That is what keeps a stranger who knows an administrator’s address from locking the administrator out. Those fresh addresses are capped at ten per account per lockout window, and an IPv6 address counts by its /64, or the lock would cap nothing. A right password clears the count. Passkey and SSO sign-in are not subject to it - neither takes a password to guess.

The last three are paced by other software rather than by a person at a keyboard, which is why they sit an order of magnitude higher. SNS retries hard and for hours, so throttling it loses bounces. A hundred nodes reporting every two minutes from behind one NAT address is fifty legitimate requests a minute, and setting that budget too low drops the whole fleet out of the pool at once.

Raise the forwarding budget, do not lower it

relay_node_inbound_per_minute is the one endpoint here whose rate strangers set, because a node’s MX takes whatever the internet sends it. It is a runaway guard, not a delivery policy: by the time a request reaches it the node has already answered 250 at the SMTP layer, so a refusal loses a message the sender believes was accepted. The per-IP filtering belongs on the node, in relay_node.inbound.rate_per_minute.

Requests in Progress

Separate from the per-minute budgets above, two settings bound how many requests are being handled at once: server.max_concurrent_requests (default 4096) for the whole node, answered 503 past it, and server.max_concurrent_per_ip (default 256) for one caller, answered 429. Both refusals use the JSON error envelope and carry Retry-After: 1. The probes, /healthz and /readyz, are never refused, so a node at its ceiling still answers its orchestrator. A request naming no credential has 30 seconds to arrive, body included, so a stranger cannot hold a slot by sending a body a byte at a time. A body above 1 MiB needs a credential that is valid, not merely named: a request whose key or session does not verify is held to 1 MiB on every route, and on the routes that carry attachments it also gets the same 30 seconds. The one exception is /api/relay-nodes/inbound: an approved relay node repeats its control credential in the X-Mailyard-Relay-Node header and is admitted up to inbound.max_message_size, and a forward without one keeps 8 MiB. See Configuration .

SMTP Listener Limits

Separate from the table above and configured where each listener is declared. Both count new sessions per client IP per minute, not messages - one session can carry many - and both refuse with a transient 421 4.7.0 so a well-behaved sender retries. 0 disables one.

SettingDefaultApplies to
submission.rate_per_minute60The submission listener, mail from an application you authenticate.
inbound.rate_per_minute120The MX-facing listener, mail from the internet.
relay_node.inbound.rate_per_minute120A relay node’s own MX, set in the config file on that machine.

The check runs when the session opens, before AUTH and before anything is parsed, so an abusive client is shed before it costs a database round trip.

Multi-node deployments

Every one of these windows is held in process memory. Across N nodes the effective ceiling is the configured value times N. Size accordingly, or terminate the limit at a shared reverse proxy. Plan-based send limits do not have this property - they count rows in the shared database.

Trusting a Proxy

Both edges key on the TCP peer address, and behind a load balancer that address is the balancer’s. Each edge has its own way of learning the real one, because HTTP and SMTP are not the same problem.

HTTP

server.trusted_proxies lists the IPs or CIDRs of your balancer, and X-Forwarded-For is then read - but only when the connection itself came from one of those hops. Without it, every request behind a proxy shares one bucket.

List every hop, not just the nearest one. The header is a list, each hop appends the address it saw, and the entries on the left are whatever the caller chose to send. So the address is resolved by reading the list from the right and stopping at the first entry that is not one of your proxies - a hop you leave out of the list becomes the answer, and a caller who sends the header cannot become someone else by doing it:

X-Forwarded-For: 8.8.8.8, 203.0.113.9, 10.0.0.7
                 ^ sent by the caller
                              ^ what your edge proxy saw, and the answer
                                            ^ your internal hop, in trusted_proxies

A chain that cannot be read to the end - an entry that is not an address, which some proxies write as unknown - falls back to the address that connected, rather than trusting the rest of the list.

SMTP

There is no X-Forwarded-For in SMTP - the protocol has nowhere to put the original address - so the balancer has to speak the PROXY protocol and the listener has to read it. Available on all three SMTP listeners, v1 and v2, off by default:

submission:
    proxy_protocol:
        enabled: true
        trusted:
            - 10.0.0.0/8      # a CIDR
            - 192.0.2.7       # or a bare address

The same block exists under inbound and, in the config file on the node itself, under relay_node.inbound. As environment variables the list is comma separated:

MAILYARD_SUBMISSION_PROXY_PROTOCOL_ENABLED=true
MAILYARD_SUBMISSION_PROXY_PROTOCOL_TRUSTED=10.0.0.0/8,192.0.2.7

Leaving it off behind a balancer costs more than a wrong log line:

  • The per-IP session limits become installation-wide. submission.rate_per_minute: 60 stops meaning 60 per client and starts meaning 60 in total, so the 61st connection in a minute is refused 421 4.7.0 no matter who opened it.
  • SPF is computed for the wrong host. The MX checks the connecting IP against the sender’s SPF record, and no sender’s record names your balancer.
  • client_ip on every inbound and sandbox message records a hop of your own.

trusted may not be empty

A PROXY header is an unauthenticated claim about who is calling. Honoring one from an arbitrary peer is worse than not reading it at all: anyone able to reach the port could assert any address, which forges an SPF pass as that sender and spends their rate budget instead of their own. Enabling the protocol without a trusted list is refused at startup.

A peer outside the list is never asked for a header at all, so its address is the real one and a PROXY ... line it writes is answered 500 as the unknown command it is. A trusted peer that sends no header is refused - a proxy always sends one, and guessing would silently put the balancer’s address back.