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."