Rate limiting
Sign-in, sign-up and one-time code requests are rate limited. Rate limits protect platform stability and ensure fair access across all integrations.
Current limits
Rate limiting is applied in three independent layers. A request can be limited by any layer that applies to it; the effective ceiling on a given route is the tightest layer.
| Layer | Applies to | Key | Limit |
|---|---|---|---|
| Edge | /api/* routes except /api/health and /api/dpp/{slug}/jsonld | Per IP address | 120 requests / minute (fixed window) |
| Public-DPP route | GET /api/dpp/{slug}, GET /api/dpp/{slug}/jsonld, GET /api/dpp/{slug}/v/{version} and the GS1 JSON-LD and link set answers on /01/... | Per IP address | 100 requests / minute |
| Integration API | /api/v1/* | Per API key | 100 requests / minute per operation family (reads and writes) |
- Public DPP reads (
GET /api/dpp/{slug}) pass through both the edge layer (120/min) and the route layer (100/min). The effective ceiling is the tighter of the two:min(120, 100)= 100 requests / minute per IP. GET /api/dpp/{slug}/jsonldskips the edge layer and is limited only by the 100/minute per-IP route layer.- The Integration API (
/api/v1) is limited per API key at 100 requests / minute, counting both reads and writes. The counter is kept per operation family (reads, draft writes, submits, and meta each have their own bucket), so heavy reading does not starve your ability to submit. Live (pk_live_) and sandbox (pk_test_) keys have separate quotas, so exercising a sandbox key never consumes your live budget. See Integration API: Errors and conventions. - Three Integration API reads are not counted against the per-key limit and do not return
RateLimit-*headers:GET /api/v1/products,GET /api/v1/products/{id}andGET /api/v1/products/{id}/gate. The per-IP edge limit still applies to them. GET /api/healthis exempt from rate limiting so liveness probes are never throttled.OPTIONSrequests,/robots.txt,/sitemap.xmland/.well-known/*are exempt from the edge layer too.
Limits are applied on a fixed-window basis: the counter resets at the end of each window.
Rate limit response headers
The headers and the 429 body depend on which layer limited the request.
Integration API (per key)
Authenticated /api/v1 responses from rate-limited routes carry the IETF RateLimit-* header names:
| Header | Type | Description |
|---|---|---|
RateLimit-Limit | integer | The maximum number of requests allowed in the current window |
RateLimit-Remaining | integer | The number of requests remaining in the current window |
RateLimit-Reset | integer | Seconds remaining until the current window resets (delta-seconds, not a Unix timestamp) |
Example headers on a healthy response:
HTTP/2 200 OK
Content-Type: application/json
RateLimit-Limit: 100
RateLimit-Remaining: 47
RateLimit-Reset: 23
Monitor RateLimit-Remaining in your integration. When it approaches zero, slow your request rate proactively before hitting the limit.
When the per-key limit is exceeded the response is 429 Too Many Requests with the same RateLimit-* headers, a Retry-After header in seconds, and this body:
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests for this API key. Slow down and retry.",
"requestId": "9b2f1c6e-4a3d-4f0e-8c55-2d7a1e6b9f10"
}
}
Edge (per IP, /api/*)
The edge layer sets X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. On a 429, X-RateLimit-Reset is a Unix timestamp in seconds, and the response also carries Retry-After in seconds. The body is:
{
"error": "Too many requests"
}
This body has no code, so branch on the HTTP status for this case.
Public DPP routes (per IP)
When the route layer of /api/dpp/... limits a request, the response is 429 Too Many Requests with Retry-After: 60 and this body. It carries no RateLimit-* headers.
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Try again later."
}
}
Reading the wait time
Read the wait time from the Retry-After response header (number of seconds until requests are accepted again). There is no retryAfter field in any response body, so do not read one.
Recommended backoff strategy
For integrations that may hit rate limits, implement exponential backoff with jitter:
async function fetchWithBackoff(
url: string,
options?: RequestInit,
maxRetries = 4
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response; // success or non-rate-limit error
}
if (attempt === maxRetries) {
return response; // exhausted retries, return the 429
}
// Read retry-after from the response header (there is no body field)
const retryAfter = Number(response.headers.get('Retry-After')) || 60;
// Exponential backoff: 1s, 2s, 4s, 8s...
const baseDelay = Math.pow(2, attempt) * 1000;
// Add jitter: random additional 0–1000ms to avoid thundering herd
const jitter = Math.random() * 1000;
// Respect the server's retryAfter if it's longer than our computed delay
const delay = Math.max(baseDelay + jitter, retryAfter * 1000);
console.warn(
`Rate limited (attempt ${attempt + 1}/${maxRetries}). ` +
`Retrying in ${Math.round(delay / 1000)}s...`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Should not reach here');
}
// Usage
const response = await fetchWithBackoff(
'https://dpp.traceable.digital/api/dpp/swiftvolt-48v-100ah-ev-pack'
);
import time
import random
import requests
def fetch_with_backoff(url: str, max_retries: int = 4, **kwargs) -> requests.Response:
"""
Fetch a URL with exponential backoff on 429 responses.
Args:
url: The URL to request.
max_retries: Maximum number of retries on rate limit. Default 4.
**kwargs: Additional arguments passed to requests.get().
Returns:
The final requests.Response object.
"""
for attempt in range(max_retries + 1):
response = requests.get(url, timeout=10, **kwargs)
if response.status_code != 429:
return response
if attempt == max_retries:
return response # exhausted retries
# Read retry-after from the response header (there is no body field)
try:
retry_after = int(response.headers.get("Retry-After", 60))
except (TypeError, ValueError):
retry_after = 60
# Exponential backoff with jitter
base_delay = (2 ** attempt) # seconds: 1, 2, 4, 8
jitter = random.uniform(0, 1)
delay = max(base_delay + jitter, retry_after)
print(
f"Rate limited (attempt {attempt + 1}/{max_retries}). "
f"Retrying in {delay:.1f}s..."
)
time.sleep(delay)
# unreachable
raise RuntimeError("fetch_with_backoff: should not reach here")
Bulk operations
If you need to fetch a large number of DPPs (for example, populating a product registry or running a compliance audit), follow these guidelines:
- Space requests: stay comfortably below the 100 requests/minute ceiling to give yourself headroom for retries
- Use caching: DPPs change infrequently. Cache responses with a TTL of at least 5 minutes and compare
lastUpdatedAt(ordpp_metadata.last_updated, or theX-DPP-Versionresponse header) to invalidate when needed. This dramatically reduces the requests needed for repeat access patterns. - Parallelise conservatively: use a concurrency limit of 3–5 simultaneous requests rather than firing all requests at once
- Contact support for higher limits: if your use case legitimately requires more than 100 requests/minute (for example, a national product registry that needs to bulk-sync all battery DPPs), contact support@traceable.digital with details of your use case and volume requirements. Higher limits are available for approved integrations.
Example of rate-limited bulk fetching in JavaScript:
import PQueue from 'p-queue'; // npm install p-queue
const queue = new PQueue({
concurrency: 3, // max 3 concurrent in-flight requests
interval: 1000, // per interval (ms)
intervalCap: 1, // 1 request/second = 60/minute, well under the 100/minute public-DPP ceiling
});
async function bulkFetchDpps(slugs: string[]): Promise<Map<string, unknown>> {
const results = new Map<string, unknown>();
await Promise.all(
slugs.map(slug =>
queue.add(async () => {
try {
const response = await fetchWithBackoff(
`https://dpp.traceable.digital/api/dpp/${slug}`
);
if (response.ok) {
results.set(slug, await response.json());
} else {
console.warn(`Failed to fetch DPP for ${slug}: ${response.status}`);
}
} catch (err) {
console.error(`Error fetching DPP for ${slug}:`, err);
}
})
)
);
return results;
}
Rate limit key behaviour
- The edge and public-DPP layers are keyed per IP address. Changing your IP address does not reset an Integration API key's per-key counter.
- The Integration API layer is keyed per API key, and counts reads and writes alike. Multiple integrations using different API keys have independent counters, and a key's live (
pk_live_) and sandbox (pk_test_) quotas are tracked separately.