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:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests admitted per window — 60. |
RateLimit-Remaining | Requests you have left in the current window, after this one. |
RateLimit-Reset | Seconds until the window resets and Remaining returns to 60. |
RateLimit-Policy | The 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=60Other 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
- On a 429, wait
Retry-Afterseconds, then retry the same request. Do not retry sooner: the counter does not budge until the window resets. - Between calls, watch
RateLimit-Remaining. If it reaches a few andRateLimit-Resetis still far off, pace yourself instead of running into the 429 — a queue that drains at one request per second never sees one. - Only 429s are rate-limit errors. A 409
idempotency_in_progressalso carriesRetry-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/meis 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 noRateLimit-*headers.POST /v1/templates/{id}/instantiatedraws from the same bucket as every other endpoint — one token per call, no separate limit. It also sends email, so a retry loop that ignoresRetry-Afteris 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.
