Rate limits

60 requests per minute per API key, counted on a shared counter. RateLimit-* headers on every successful response and every 429, Retry-After on 429.

Every API key gets 60 requests per 60-second window, one bucket across all /v1/* endpoints: sends, reads, remind/withdraw, template generate and instantiate, signed-PDF and audit-trail downloads all count against it. A request that ends in a 404 or a validation error has still spent a token. Only GET /v1/me is exempt (see below).

Per key — so per workspace

An API key belongs to exactly one workspace, so the limit is effectively per workspace: another tenant hammering the API cannot slow yours down, and nothing you do affects anyone else. The bucket is not pooled across the keys of one workspace: each key has its own counter, so two keys are two buckets. Rotate keys freely; don't mint keys to multiply throughput — ask us instead (below).

How the counter works

Every admitted request row-locks your key's row in Postgres and bumps a counter. The lock is what makes the count atomic across all of our function instances and regions, so the ceiling is exactly 60 however your burst is spread. The window is fixed, not sliding: it opens on your first request and closes 60 seconds later, after which the counter starts from zero. A refused request (429) never increments the counter, so a retry that honours Retry-After succeeds the moment the window resets.

If the shared counter is unreachable (a database fault, a migration lag on a fresh deploy) the same 60/60 policy is applied in-process on whichever instance served you instead of the call being waved through. That fallback counts per instance, so for as long as it lasts the effective ceiling under load can be somewhat higher than 60 — never unlimited. You are unlikely to ever see it; we would rather say so than pretend the counter is infallible.

Headers

Every successful response and every 429 carries the IETF draft RateLimit header fields:

HeaderMeaning
RateLimit-LimitRequests admitted per window — 60.
RateLimit-RemainingRequests you have left in the current window, after this one.
RateLimit-ResetSeconds until the window resets and Remaining returns to 60.
RateLimit-PolicyThe policy the numbers follow: 60;w=60 (60 per 60-second window).
HTTP/1.1 201 Created
Content-Type: application/json
RateLimit-Limit: 60
RateLimit-Remaining: 41
RateLimit-Reset: 23
RateLimit-Policy: 60;w=60

Other error responses (400, 402, 404, 409, 5xx) do not carry them. Read the headers rather than counting calls yourself — they reflect the counter every instance shares.

Going over the limit

429 with a JSON body and a Retry-After in seconds — the seconds left in the current window:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 23
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23
RateLimit-Policy: 60;w=60

{
  "error": "Rate limit exceeded — retry in 23s",
  "code":  "rate_limited"
}

Pin your handling to code === "rate_limited"; the error text is for humans and may improve.

Retrying well

  1. On a 429, wait Retry-After seconds, then retry the same request. Do not retry sooner: the counter does not budge until the window resets.
  2. Between calls, watch RateLimit-Remaining. If it reaches a few and RateLimit-Reset is still far off, pace yourself instead of running into the 429 — a queue that drains at one request per second never sees one.
  3. Only 429s are rate-limit errors. A 409 idempotency_in_progress also carries Retry-After, but for a different reason; treat every other 4xx as a bug in the request, not as something to retry on a timer.
async function withRateLimit(call: () => Promise<Response>) {
  for (let attempt = 1; attempt <= 5; attempt++) {
    const res = await call()
    if (res.status !== 429) return res
    const wait = Number(res.headers.get('retry-after')) || 60
    await new Promise((r) => setTimeout(r, wait * 1000))
  }
  throw new Error('Rate-limited after 5 retries')
}

Idempotency-Key survives a 429. The limit is checked before the idempotency lock is taken, so a refused request never claims your key. Send the same Idempotency-Key on the retry; it acquires the lock on the first pass-through. See Idempotency.

Endpoints with their own rules

  • GET /v1/me is exempt. Connectors ping it as their "test authentication" step and on a schedule, it is a single primary-key read, and a 429 there would look like a broken key to exactly the person checking whether the key works. It consumes no token and reports no RateLimit-* headers.
  • POST /v1/templates/{id}/instantiate draws from the same bucket as every other endpoint — one token per call, no separate limit. It also sends email, so a retry loop that ignores Retry-After is a reputation problem as well as a load problem.

If you need higher throughput

Email support@letssign.now with your workspace ID and the pattern you need (sustained vs. spike, total daily volume). Sane requests are agreed quickly and without an SLA negotiation; high-volume use cases (>5k/day) typically move to a custom tier.