Rate limits
Limits are counted per API key, not per user or per IP. Two separate limits apply.
| What | Limit | Exceeding it returns |
|---|---|---|
Token exchange (POST /api/v1/oauth/token) | 60 requests/minute | 429 with rate_limited |
Import endpoints (/api/v1/developer/import/*) | 20 requests/minute | 429 |
Direct uploads to the presigned S3 URLs do not count against either limit — they don't go through Marvin. Uploading 100 CSV parts costs one initialize call, not 100.
Staying under the limits#
Cache your token. An access token is valid for a full hour. Exchanging a new one per API call is the most common way to hit the 60/min limit. Keep the token in memory and refresh it only when it's near expiry — see Token lifecycle.
Back off when polling. Status endpoints count toward the 20/min import limit, so a tight polling loop can rate-limit itself. Poll every 5–10 seconds; the examples in the guides use time.sleep(5) for CSVs and insights and time.sleep(10) for files, which is comfortably inside the limit for a single import.
Batch what the API already batches. Ask for all your CSV parts in one initialize call rather than one call per part, and register all media filenames in one register call.
Handling a 429#
Respect the Retry-After response header when it's present, and retry with exponential backoff otherwise. A 429 means the request never ran, so retrying is safe — no partial work was done.
For import flows where a duplicate could otherwise create a second record, pass an idempotency_key to survey initialize so a retry returns the existing upload instead of creating a new one. Note that insight initialize has no idempotency key — retrying it creates a new insight each time.