Conventions
Errors
One error envelope on every public endpoint, a closed list of codes, and what to do about each one.
Every error from /v1 and /v2 has the same shape:
{
"error": {
"code": "validation_failed",
"message": "Invalid query parameters.",
"details": [{ "path": "query.limit", "message": "Number must be less than or equal to 100" }],
"requestId": "4f6c1d2e-8a3b-4c5d-9e0f-1a2b3c4d5e6f"
}
}| Field | |
|---|---|
code | One of the codes below. Branch on this, never on message. |
message | A sentence for a human. It may be reworded at any time. |
reason | Optional. A finer sub-code for some refusals, such as ip_not_allowed. |
details | Optional. For validation_failed, a list of { path, message }, one per bad field. |
requestId | The same id as the X-Request-Id header. Quote it to support. |
A 5xx never carries a stack trace or internal detail: its message is always "Something went wrong
on our side. Quote the request id if you contact support."
Codes
The list is closed: a new code is an additive change, and renaming one is a breaking change that needs a new version.
| HTTP | code | When | What to do |
|---|---|---|---|
| 400 | validation_failed | A parameter, header or body field is invalid. details names each one. Also: a missing Idempotency-Key on a POST, an invalid cursor, an unknown Gauge-Version. | Fix the request. Do not retry unchanged. |
| 401 | unauthorized | No key, or an unknown, revoked or expired one. | Check the key. |
| 401 | key_owner_inactive | The key's member is no longer active in the workspace. | Create a new key under an active member. |
| 402 | plan_required | The workspace is not on Growth or Scale. | Upgrade in Settings → Billing. |
| 403 | forbidden | Refused for a reason in reason: ip_not_allowed, sandbox_key_not_supported, key_pinned_to_projects. | See Authentication. |
| 403 | insufficient_scope | The key, or its member's role, lacks the route's scope. details.required names it. | Add the scope, or use another key. |
| 403 | workspace_suspended | The workspace is suspended. | Contact Gauge support. |
| 404 | not_found | The resource does not exist, or this key cannot see it. | Check the id. Gauge does not tell you which. |
| 409 | conflict | The request clashes with the current state, or an identical request is still running (reason: idempotency_in_flight). | Re-read the resource, or retry shortly. |
| 409 | idempotency_mismatch | An Idempotency-Key was reused with a different body. | Use a new key for a new request. |
| 413 | payload_too_large | The body or file is over the limit. | Send less. |
| 422 | unsupported_channel_action | The channel cannot do this, such as media on a channel without media, or free text outside WhatsApp's 24-hour window. | Check the connection's capabilities. See Channels. |
| 429 | rate_limited | Over the rate limit. | Wait Retry-After seconds, then retry. |
| 500 | internal | Our fault. | Retry with backoff. If it persists, contact support with the request id. |
404 is also the answer for another workspace's ids and for anything outside the key's visibility.
Gauge never confirms that a resource exists to a caller who may not see it.
Retrying safely
| Retry? | Codes |
|---|---|
| Yes, after a wait | 429 rate_limited (honour Retry-After), 500 internal, network errors and timeouts |
| Yes, shortly | 409 conflict with reason: idempotency_in_flight |
| No, fix first | Every other 4xx |
Send POSTs with an Idempotency-Key and every retry is safe: a request
that already succeeded replays its first response instead of running twice.
async function gauge(path, init = {}, attempt = 0) {
const res = await fetch(`https://app.gauge.chat/backend${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.GAUGE_API_KEY}`, ...init.headers },
});
if (res.ok) return res.status === 204 ? null : res.json();
const { error } = await res.json();
const retryable = res.status === 429 || res.status >= 500 || error.reason === 'idempotency_in_flight';
if (retryable && attempt < 4) {
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000 + Math.random() * 250));
return gauge(path, init, attempt + 1);
}
throw Object.assign(new Error(error.message), { code: error.code, reason: error.reason, requestId: error.requestId });
}