Rate limits¶
Limits at a glance¶
Every hard ceiling a partner integration can hit — request size, list-endpoint page sizes, OAuth token lifetimes, idempotency, attachments, and migration/webhook ceilings. Every NUMBER below is read live from the setting or model field it documents, so it can't drift the way a hand-typed figure could; a few rows describe behaviour (which errors are compared, what gets clamped vs. rejected) that isn't a single constant to source — those are prose, backed by a pin test instead, named in each section so you know which is which:
Every non-rate-limit ceiling a partner integration can hit. Rate limits (requests per minute/day) are below this section, in the same page.
Request size¶
| Limit | Value |
|---|---|
| Max request body size | 10485760 bytes (10 MB) |
An oversized /api/v1/ request body — JSON or form-encoded — is rejected with 413 request_too_large (the same envelope every partner endpoint uses: {code, message, details, request_id}) BEFORE it reaches any receiver. Two middleware, both scoped to /api/v1/ only, share this behaviour: apps.core.middleware.nul_strip.NulByteStripMiddleware's request.POST access catches it for a form-encoded/multipart body, and apps.core.idempotency.IdempotencyMiddleware's request.body read catches it for every other body (the real partner API shape, JSON). On migration batch-submit specifically, this 413 is the plain partner envelope — it does NOT carry the X-LuciBook-Migration-Version header the route's own 200/202/4xx/5xx responses do, because the rejection happens before that view runs. OAuth endpoints outside /api/v1/ (/oauth/token/, /oauth/revoke_token/, /oauth/authorize/) are not covered by either middleware and keep Django's own behaviour. Pinned by tests/core/test_idempotency.py::test_oversized_body_on_exempt_path_returns_413_json_request_too_large (JSON) and test_oversized_form_encoded_body_on_exempt_path_returns_413_via_nul_strip (form-encoded), and by tests/integrations/test_errors_catalogue_matches_core.py for the envelope message.
DATA_UPLOAD_MAX_NUMBER_FIELDS (5000) applies to form-encoded bodies and query strings, not JSON request bodies — DRF's JSONParser reads the body directly and never populates request.POST. It only bounds the form-encoded OAuth endpoints (/oauth/token/, /oauth/revoke_token/, /oauth/authorize/) and any endpoint's query string, both of which stay far below it in normal use.
List endpoint page sizes¶
| Resource | Page-size param | Default | Max |
|---|---|---|---|
| Bills / Invoices / Credit notes | limit/offset |
100 |
500 |
| Contacts | limit/offset |
100 |
500 |
| Accounts / Tax rates | none | — | unpaginated — limit/offset are ignored and the entire active list is returned every time (pinned by tests/integrations/test_read_apis.py::test_accounts_ignores_limit_and_offset_returns_everything and test_tax_rates_ignores_limit_and_offset_returns_everything) |
| Webhook deliveries | page/page_size |
50 |
200 |
| Migration reference data (contacts) | page |
— | fixed 500 rows/page |
| Migration id-map | page |
— | fixed 1000 rows/page |
A limit above the max is silently clamped, not rejected (pinned by tests/integrations/test_documents_read_api.py::test_bills_list_limit_above_max_clamps_not_400). See Pagination and filtering for the full limit/offset response shape.
OAuth token lifetimes¶
| Token | Lifetime |
|---|---|
| Access token | 1800 seconds |
| Authorization code | 60 seconds (django-oauth-toolkit's library default — LuciBook does not override it; pinned by tests/integrations/test_settings.py::test_authorization_code_lifetime_is_not_overridden) |
Refresh tokens have no absolute lifetime. ROTATE_REFRESH_TOKEN=True means every successful refresh mints a new refresh token and revokes the old one, so nothing expires it by elapsed time alone — REFRESH_TOKEN_EXPIRE_SECONDS (7776000 seconds) is only read by django-oauth-toolkit's clear_expired_tokens/cleartokens management command, which LuciBook never runs. The real ceiling is inactivity: a refresh token whose current row is older than 60 days is refused with invalid_grant (developer.refresh_token_inactivity_days, apps.integrations.services.oauth_validator.LuciBookOAuth2Validator.validate_refresh_token) — each successful refresh resets that clock by minting a fresh row.
Idempotency¶
See Idempotency for the full per-endpoint contract — what's compared on replay (it differs between the payroll create and correct endpoints), key scope, and retry behaviour. Just the two numbers that are a single value per family, safe to generate:
| Endpoint family | Idempotency-Key max length |
Retention |
|---|---|---|
| Bills / Invoices / Credit notes | 255 |
48 hours, then purged by an hourly cron |
| Payroll pay-runs (create and correct) | 128 |
no expiry — lives with the PayRun row |
Migration batches (must equal batch_id) |
64 |
no expiry — lives with the MigrationBatch row |
Migration attachments (must equal {batch_id}:attachments:{sha256}) |
no separate ceiling — checked by exact equality, not length | no expiry, and no stored reply cache — the endpoint is idempotent by construction (blob + link dedup) |
Attachments¶
| Limit | Value |
|---|---|
| Max decoded content size | 7340032 bytes (~7 MB) |
| Max filename length | 255 characters |
| Allowed MIME types | application/pdf, image/heic, image/jpeg, image/png, image/tiff, image/webp |
An oversized attachment is 422 attachment_too_large; an unsupported MIME type is 422 attachment_type_unsupported. The content-size figure is chosen to sit comfortably under the request body limit above once base64's ~33% expansion is accounted for.
Migration batches¶
- A single-request batch (
chunk_countunset or1) is capped by the request body limit above (10 MB) — there is no separate, smaller migration package limit for it. - A large migration is split across several
POST .../batchesrequests instead (chunk_no/chunk_count > 1, phase 2, 2026-09-24): the server stores each chunk, merges them when the last one arrives, and loads the merged package exactly once.
| Chunking limit | Value |
|---|---|
| Max chunks per batch | 200 |
| Max total package size, summed across all of a batch's chunks | 104857600 bytes (~100 MB) |
Exceeding the chunk-count limit is 422 invalid_chunk; exceeding the total-byte limit is 413 package_too_large. Chunks must arrive in order starting at 1 (409 chunk_out_of_order otherwise), every chunk after the first must repeat chunk 1's header fields exactly (422 chunk_header_mismatch), and a replayed chunk_no with different content is 409 chunk_mismatch.
Migration attachments¶
POST .../batches/{batch_id}/attachments is a multipart/form-data request — its metadata + file parts obey the request body limit above (10 MB) the same as any other /api/v1/ body; a 7 MiB file plus its metadata comfortably fits under it.
| Limit | Value |
|---|---|
| Max per-file content size | same as the Attachments section above (7340032 bytes, ~7 MB) — same MIME allowlist too |
Max parents entries per upload |
20 |
| Max distinct files per batch | 50000 |
| Max total attachment bytes per batch | 21474836480 bytes (~20 GiB), counted over newly created blobs only — a dedup hit against an existing blob costs nothing extra |
GET .../attachments page size |
fixed 1000 rows/page |
Attachments are accepted only once a commit batch reaches done (409 batch_running while it is still queued/running/receiving/rolling back, 409 batch_not_attachable for a failed/rolled-back batch or a dry run — a dry run never posts anything, so nothing exists yet to attach a file to). A rollback unlinks every attachment THIS batch created and schedules its now-unreferenced blobs for the same 30-day soft-delete grace the Attachments section above already covers.
Webhooks¶
| Limit | Value |
|---|---|
| Max subscriptions per app | 10 |
| Delivery timeout (connect + read) | 5 seconds |
| Response body read cap | 65536 bytes |
| Auto-disable after | 50 consecutive failures |
| Retry schedule (seconds after previous attempt) | 60, 300, 1800, 7200, 43200 (±20% jitter; a transient failure's own Retry-After is honoured instead, clamped up to 43200s) |
Full webhook policy defaults (including delivery/response-cap rationale) are in Platform policy defaults.
Rate limits¶
| Setting | Default | Meaning |
|---|---|---|
developer.rate.migration.max_concurrent_batches |
2 |
Max concurrently-running migration batches per org. |
developer.rate.migration.per_app_org_per_minute |
600 |
Migration API per (app, org) per-minute budget. |
developer.rate.partner.per_app_org_per_day |
5000 |
Partner API per (app, org) per-day budget. |
developer.rate.partner.per_app_org_per_minute |
60 |
Partner API per (app, org) per-minute budget. |
developer.rate.partner.per_app_per_minute |
1000 |
Partner API per-app per-minute budget. |
developer.rate.unverified_multiplier |
0.5 |
Multiplier applied to rate limits for unverified apps. |
On every response the triple X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset describes the tightest budget that applies to this request. On a 429 it instead describes the budget that tripped, and X-Rate-Limit-Problem names which one (minute, day or app). Retry-After is seconds until the next window. Windows are fixed UTC clock windows, not sliding.
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Rate-Limit-Problem, Retry-After.
Platform policy defaults¶
The full set of staff-editable platform defaults (including secret rotation grace and refresh-token inactivity, referenced elsewhere in these docs):
Platform policy defaults¶
Staff-editable via the system_settings table; these are the code-side defaults every fresh environment starts with.
| Key | Default | Kind | Meaning |
|---|---|---|---|
developer.access_log_enabled |
True |
bool | D-B3-01 — B4's kill-switch for the synchronous access-log write. |
developer.access_log_retention_days |
395 |
int | D-B3-01 — read-access log retention (spec §12, ~13 months). |
developer.app_create_ip_limit_per_hour |
3 |
int | Per-IP app-create throttle. |
developer.custom_connection_billable |
False |
bool | Billing hook stub (§14) — nothing is charged yet. |
developer.max_apps_per_owner |
5 |
int | Owner cap enforced at app create. |
developer.notify_owners_on_connect |
True |
bool | E-mail org OWNERs on a new connection. |
developer.rate.migration.max_concurrent_batches |
2 |
int | Max concurrently-running migration batches per org. |
developer.rate.migration.per_app_org_per_minute |
600 |
int | Migration API per (app, org) per-minute budget. |
developer.rate.partner.per_app_org_per_day |
5000 |
int | Partner API per (app, org) per-day budget. |
developer.rate.partner.per_app_org_per_minute |
60 |
int | Partner API per (app, org) per-minute budget. |
developer.rate.partner.per_app_per_minute |
1000 |
int | Partner API per-app per-minute budget. |
developer.rate.unverified_multiplier |
0.5 |
float | Multiplier applied to rate limits for unverified apps. |
developer.refresh_token_inactivity_days |
60 |
int | Auth-code refresh tokens unused this long are refused. |
developer.secret_rotation_grace_days |
7 |
int | Grace window a rotated-out secondary client secret keeps working. |
developer.self_service_enabled |
True |
bool | Global kill-switch for developer app self-service creation. |
developer.unverified_max_connections |
25 |
int | Connection quota for an unverified app. |
developer.verified_max_connections |
None |
nullable_int | Connection quota for a verified app; null = unlimited. |
developer.verified_only_scopes |
['payroll:read', 'payroll:write'] |
str_list | Scopes withheld from unverified apps. |
developer.webhook.delivery_retention_days |
30 |
int | D-C4-12 — how long delivery history + payload rows are kept. |
developer.webhook.disable_after_failures |
50 |
int | Consecutive delivery failures before auto-pause. |
developer.webhook.max_subscriptions_per_app |
10 |
int | Max webhook subscriptions per app. |
developer.webhook.response_cap_bytes |
65536 |
int | Max bytes read from a webhook receiver's response body. |
developer.webhook.retry_schedule_seconds |
[60, 300, 1800, 7200, 43200] |
json | Delivery retry schedule in seconds (1-10 entries, each >= 10); a delivery that exhausts this schedule stays failed, not dead — only a 410 response, a terminal egress refusal, or the stale-lease reaper (and only once no attempts remain, else it requeues the delivery as failed) moves a delivery to dead (DLQ). |
developer.webhook.signature_tolerance_seconds |
300 |
int | Documented signature timestamp tolerance for receivers. |
developer.webhook.timeout_seconds |
5 |
int | Egress timeout (connect + read) for a webhook challenge or delivery. |
Backoff advice¶
- Treat
429as a signal to slow down, not to retry immediately: honourRetry-Afterexactly. - If you're hitting the per-minute budget repeatedly, batch reads instead of polling in a tight loop.
- A sustained daily-budget trip usually means a design problem (polling instead of webhooks) rather than a quota that needs raising — consider subscribing to webhooks for the resources you currently poll.