Skip to content

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_count unset or 1) 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 .../batches requests 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 429 as a signal to slow down, not to retry immediately: honour Retry-After exactly.
  • 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.