Error codes
A complete catalog of every error code Synq returns, with examples and what to do about each one.
OAuth/OIDC errors
Returned at /oauth2/authorize (as a redirect with ?error=),
/oauth2/token (JSON body), /oauth2/revoke, and /oauth2/introspect.
Format follows RFC 6749 §5.2:
{
"error": "invalid_grant",
"error_description": "Authorization code expired"
}error | When | What to do |
|---|---|---|
invalid_request | Missing or malformed parameter. | Check the request body matches the endpoint’s required params. |
invalid_client | Bad client_id / client_secret. | Verify credentials; check for stale secrets after rotation. |
invalid_grant | Auth code expired (60s), refresh token consumed, PKCE mismatch, or device code expired. | Restart the flow. |
unauthorized_client | App not allowed to use the requested grant type. | Check allowedGrantTypes on the App. |
unsupported_grant_type | Grant type not enabled. | Same as above. |
invalid_scope | Scope not in app’s allowedScopes or unknown. | Verify scope is registered on the Brand and on the App. |
access_denied | User denied consent. | Surface a friendly “consent was denied” page. |
consent_required | prompt=none but no existing consent. | Re-prompt the user with prompt=consent (or omit prompt). |
login_required | prompt=none but no active session. | Re-prompt with full sign-in. |
authorization_pending | Device flow: user has not approved yet. | Keep polling at interval seconds. |
slow_down | Device flow: poll less often. | Increase polling interval by 5 seconds. |
expired_token | Device flow: device_code expired (10 min). | Restart the device flow. |
Example: handling invalid_grant on refresh
try {
const tokens = await synq.refresh(refreshToken)
saveSession(tokens)
} catch (err) {
if (err.code === 'invalid_grant') {
// The refresh token is no longer valid. Force a fresh sign-in.
redirectTo('/sign-in')
return
}
throw err
}Example: handling user denial
// On /api/oauth/callback
const url = new URL(req.url)
const error = url.searchParams.get('error')
if (error === 'access_denied') {
return res.redirect('/sign-in?status=cancelled')
}REST errors
Returned by all non-OAuth endpoints. Envelope:
{
"error": {
"code": "rate_limited",
"message": "Too many requests",
"details": { "retryAfter": 60 }
}
}| HTTP | code | When |
|---|---|---|
| 400 | validation_failed | Body did not pass validation. details includes per-field errors. |
| 401 | unauthorized | No bearer token, or token invalid / expired. |
| 401 | invalid_token | Token signature/exp/aud invalid. |
| 403 | forbidden | Authenticated but lacks permission. details.missing lists required permission. |
| 404 | not_found | Resource does not exist or caller cannot see it. |
| 409 | conflict | State conflict (duplicate slug, in-use role). |
| 422 | unprocessable_entity | Body passed validation but request semantically invalid (e.g. trying to delete the last Owner). |
| 429 | rate_limited | Per-key rate limit hit. Retry-After header set. |
| 500 | internal_error | Synq-side bug. Safe to retry with backoff. |
| 502 | bad_gateway | Downstream dependency error (e.g. OAuth provider 500). |
| 503 | service_unavailable | Synq degraded. Retry with backoff. |
| 504 | gateway_timeout | Downstream dependency timed out. |
Example: validation_failed with field errors
{
"error": {
"code": "validation_failed",
"message": "Invalid request body",
"details": {
"fields": [
{ "field": "redirectUris[0]", "code": "invalid_url", "message": "must be a valid HTTPS URL" },
{ "field": "name", "code": "too_short", "message": "must be at least 3 characters" }
]
}
}
}Example: rate_limited
{
"error": {
"code": "rate_limited",
"message": "Too many requests",
"details": { "retryAfter": 30, "limit": 100, "window": 60 }
}
}Headers:
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1717000000Respect Retry-After and back off. Don’t retry within the window.
Webhook delivery errors
Synq does not surface webhook delivery errors back to your app; it
logs them and exposes them via GET /webhooks/:id/deliveries.
status | When |
|---|---|
succeeded | Your URL returned 2xx within 10s. |
failed_timeout | No response within 10s. |
failed_status | Returned non-2xx. responseCode populated. |
failed_network | DNS, TLS, or transport error. |
disabled | Webhook is disabled (auto-disabled after 20 consecutive failures, or manually). |
pending | In the retry queue. |
Wallet-link errors
Returned by POST /users/me/public-keys:
code | Meaning |
|---|---|
bad_signature | Signature does not verify against the address. |
stale_challenge | The signed challenge is too old (>5 min). |
replayed_nonce | Nonce was already used. Generate a fresh challenge. |
wrong_brand | The challenge’s brand name doesn’t match the brand the user is signing in to. |
wallet_in_use | This wallet is already linked to a different user. |
Mental model summary
- OAuth/OIDC errors use RFC 6749 §5.2 envelope.
- REST errors use the
{ error: { code, message, details } }envelope. - Webhook delivery errors live in the deliveries log, not at-request.
- For 401, distinguish “no token” (
unauthorized) from “bad token” (invalid_token). The first is a sign-in prompt; the second is often a refresh-then-retry. - For 429, always respect
Retry-After.
Last updated on