Rate Limits

What's enforced today, what's in the schema, and how to set per-key RPM/RPD on the API keys you create.

Last updated May 19, 2026

Rate limiting on BetterSuite's GraphQL API is a partially shipped feature. The data model is in place — every API key carries an rateLimitRpm (requests per minute) and rateLimitRpd (requests per day) — but the HTTP layer that would enforce those numbers is not wired up yet. This article documents what's real, what's coming, and how to write a client that will Just Work once the limiter ships.

What's in the schema today

Every entry in apiKeys exposes two nullable limits:

FieldTypeMeaning
rateLimitRpmInt (nullable)Requests-per-minute cap. null = no per-key cap.
rateLimitRpdInt (nullable)Requests-per-day cap. null = no per-key cap.

You set them at creation time via the CreateApiKeyInput (fields rateLimitRpm and rateLimitRpd). The values stick across key rotation: rotateApiKey copies them from the old key onto the new one.

query MyKeys {
  apiKeys(includeRevoked: false) {
    id
    name
    keyPrefix
    rateLimitRpm
    rateLimitRpd
  }
}

Source: backend/crates/tenant/domain/src/api_key/api_key_entity.rs:75-79, website/schema.graphql:3361 (ApiKeyInfo.rateLimitRpm / rateLimitRpd).

What's actually enforced today

A handful of application-level rate limits exist in specific use cases — they're not a global request budget:

Use caseLimitSource
Resending the tenant-registration verification emailOne-minute cooldown on resendVerification (TenantError::ResendRateLimitExceeded)crates/tenant/application/src/registration/resend_verification_usecase.rs:40
Repeated order cancellations by a customerTracked via customer_strikes and converted into a soft block, not a 429crates/order/domain/src/customer_strikes/customer_strike_kind.rs
Outbound email providersrate_limit_per_minute / rate_limit_per_hour on email_providers (governs the SDK call out to SendGrid / MailerSend, not your inbound traffic)email_providers table

None of these emit 429. They surface as a GraphQL error in the response body.

What's not there (yet)

These are deliberate gaps right now — don't write code that relies on them:

  • No X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset response headers. The Axum router doesn't insert them.
  • No 429 Too Many Requests response. A grep of backend/crates/monolith for TOO_MANY_REQUESTS returns nothing.
  • No Retry-After header. Same reason.
  • No shared bucket between mutations, queries, and subscriptions. When enforcement ships it will almost certainly be per-API-key first; the buckets aren't wired.
  • No per-operation budgets (e.g. "admin exports are 1/min regardless of overall RPM"). Not in the schema, not in the middleware.

The earlier version of this page advertised specific numbers (default: 600 RPM / 50000 RPD, high: 3000 / 500000, etc.) and a JSON body shape for the 429 error. None of that is in the code today — the numbers were aspirational, not source-of-truth. They've been removed.

What to do as a client today

  1. Set the fields when you create a key. Open the Owner Dashboard, create an API key, fill in RPM and RPD to whatever you'd want enforced. They persist immediately.
  2. Build the retry shape into your client anyway. When enforcement ships it will follow the standard pattern — HTTP 429 + Retry-After. Today, treat any 5xx the same way (exponential backoff, jitter, circuit breaker if you see sustained failures).
  3. Don't depend on a specific number being honored. Even after enforcement ships, your tenant might run with null (no cap) until you pick one.

A reasonable retry loop for the current state:

async function callGraphQL(query, variables, attempt = 0) {
  const res = await fetch("https://api.bettersuite.io/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Api-Key": process.env.BETTERSUITE_API_KEY!,
    },
    body: JSON.stringify({ query, variables }),
  });

  // When 429 enforcement ships, this branch will start firing. Until then
  // it's defensive code.
  if (res.status === 429 && attempt < 5) {
    const retryAfter = Number(res.headers.get("retry-after") ?? "1");
    await new Promise(r => setTimeout(r, (retryAfter * 1000) + Math.random() * 500));
    return callGraphQL(query, variables, attempt + 1);
  }

  if (res.status >= 500 && attempt < 3) {
    await new Promise(r => setTimeout(r, 2 ** attempt * 1000 + Math.random() * 500));
    return callGraphQL(query, variables, attempt + 1);
  }

  return res.json();
}

Counting requests

A single HTTP POST to /graphql is one request, regardless of how many operations or aliases are bundled into the query body. Batching is the cleanest way to stay under any future limit — it'll cost you one tick of the bucket, not N.

GraphQL subscriptions use a different transport (/ws, graphql-transport-ws) — they're long-lived and won't share the GraphQL POST bucket when enforcement does ship.

Local development

There's nothing to disable locally: the limiter doesn't exist yet on any deployment. If you're running the monolith against a Docker Postgres, your traffic is unmetered. Don't infer prod headroom from local benchmarks regardless — Postgres + Redis under load look very different on a beefy bare-metal host than on a laptop.

What's next

Build the foundation once. Expand without limits.

BetterSuite is built for teams who see on-demand as a business — not a feature.