Skip to main content

Error handling

Error response structure

Most error responses return a JSON object with a message field. Validation errors (400) add an errors array naming the parameter at fault:

{
"message": "Bad request",
"errors": [
{
"key": "filterBehaviour",
"messages": [
"Parameter filterBehaviour is invalid. Valid values: allOf, anyOf"
]
}
]
}

These fields are for developer debugging only — drive your UI from the status code, not the message. Note that not every error body is JSON: 404 returns an empty body and firewall errors on pdn.epidemicsound.com return HTML, so parse defensively.

HTTP status codes

CodeNameWhen it occursRetry?Safe to show users?
400Bad RequestMissing or invalid request parameters (e.g. calling /v0/sound-effects/search without a term), or the request URL exceeds 4096 bytes (e.g. too many track IDs in a single call to /v0/tracks/metadata — batch in groups of 50)NoNo — show a generic "something went wrong" message
401UnauthorizedMissing, expired, or invalid tokenAfter re-authenticatingPrompt user to log in again
403ForbiddenValid API key but insufficient permissions for this resource (e.g. accessing an endpoint outside your plan)NoNo — this is a configuration issue
404Not FoundThe requested resource does not existNoYou can show "not found" if the resource is user-visible
406Not AcceptableFirewall block on audio delivery (pdn.epidemicsound.com), usually a sustained download rate that resembles scraping. Not content negotiation — changing Accept won't helpYes, after a 2s delayNo — retry silently
429Too Many RequestsRate limit exceeded — per-second, daily app, or per-user limit reachedYes, with backoffNo — handle silently with a retry
500Internal Server ErrorAn unexpected error occurred on the API side. May be transient or triggered by a specific request — retry once, then report if it persistsOnce, then reportNo — retry silently
502Bad GatewayAn internal error — the request did not complete between our edge and the API. Usually transientYes, with backoffNo — retry silently
503Service UnavailableTemporary service outageYes, with backoffNo — retry silently

Respect Retry-After

Any retryable response may carry a Retry-After header telling you how long to wait. Whenever you retry a request, respect that header if it is present and fall back to your own backoff schedule when it is absent. The header comes in two formats — a delay in seconds (Retry-After: 120) or an HTTP date (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT) — so parse both.

A 406 is retryable, but wait at least 2 seconds first — retrying immediately from the same IP reinforces the block. If it persists across retries the IP may need unblocking manually, so contact us with your egress IPs and a time window.

For details on rate limit headers, retry strategies, and DDoS protection, see Troubleshooting.

Error handling example

async function errorMessage(response) {
try {
const body = await response.json()
return (
body.errors
?.map((e) => `${e.key}: ${e.messages.join(', ')}`)
.join('; ') ?? body.message
)
} catch {
return response.statusText // 404 is empty, firewall errors are HTML
}
}

// Retry-After is either a delay in seconds or an HTTP date
function retryAfterMs(response) {
const value = response.headers.get('Retry-After')
if (!value) return null

const seconds = Number(value)
if (!Number.isNaN(seconds)) return seconds * 1000

const date = Date.parse(value)
return Number.isNaN(date) ? null : Math.max(0, date - Date.now())
}

async function apiFetch(url, options, maxRetries = 3) {
let attempt = 0

while (attempt < maxRetries) {
const response = await fetch(url, options)

if (response.ok) {
return response.json()
}

const status = response.status

if (status === 406) {
// Firewall block — wait at least 2s, an immediate retry makes it worse
const waitMs = Math.max(retryAfterMs(response) ?? 0, 2000 * (attempt + 1))
await new Promise((resolve) => setTimeout(resolve, waitMs))
attempt++
continue
}

if (status === 401) {
// API key is missing or invalid — check your credentials and do not retry automatically
throw new Error('Invalid API key — check your credentials')
}

if (status === 429 || status === 502 || status === 503) {
// Respect Retry-After when present, otherwise back off exponentially
const waitMs = retryAfterMs(response) ?? Math.pow(2, attempt) * 1000

await new Promise((resolve) => setTimeout(resolve, waitMs))
attempt++
continue
}

if (status === 500) {
// Retry once — a 500 can be transient, but retrying repeatedly won't
// help if it's triggered by the request itself (e.g. accessing a
// resource outside your plan). Report it if it persists.
if (attempt === 0) {
await new Promise((resolve) =>
setTimeout(resolve, retryAfterMs(response) ?? 1000)
)
attempt++
continue
}
throw new Error(
`API error 500: ${await errorMessage(
response
)} (report this if it persists)`
)
}

// Non-retryable error
throw new Error(`API error ${status}: ${await errorMessage(response)}`)
}

throw new Error('Max retries exceeded')
}
What to show users

Use the HTTP status code to drive UI decisions, not the message field. For example:

  • 401: "Your session has expired. Please log in again."
  • 404: "This track is no longer available."
  • 429 / 502 / 503: Retry silently in the background; only show a message if all retries fail.
  • 500: Retry once. If it keeps failing, report the error to Epidemic Sound.
  • Everything else: "Something went wrong. Please try again later."