Concepts
Authentication
The X-API-Key header, how the key resolves the tenant and scopes, what 401 and 403 mean, the IP allowlist and signing high-value requests.
On this page
Every request to the gateway carries one authentication header:
X-API-Key: cmp_...There’s no OAuth, no session tokens and no tenant id in the URL: the key is self-identifying.
The gateway looks it up, checks that it’s active and not expired, and derives from it the tenant (your
entity), the scopes, the environment (sandbox/prod), the per-minute limit and the monthly
quota. The SDKs set the header for you on every call.
What gets validated, in order #
- Presence. No
X-API-Key→401. - Valid key. Nonexistent, revoked, suspended or expired →
403. - Allowed IP. If the key has an IP allowlist, the source IP must fall within its
ranges (behind proxies,
X-Forwarded-Foris only trusted from known proxies) → otherwise403. - Scope. The operation requires a scope (for example
wallet:transfer); if the key doesn’t have it →403. - Contracted module. Even if the key has the scope, your entity must have the module enabled →
403. - High-value signature (optional, per key): see below →
401if it’s missing or invalid. - Limits. Per-minute limit and monthly quota →
429(Rate limits).
Each operation’s scope appears in the API reference, and the per-module table is in Get your key.
Checking the key #
GET /v1/gateway/health/ accepts any valid key and returns the tenant and scopes: call it
when your service starts so it fails fast if the key doesn’t work.
from compense_gateway import AuthError
try:
health = client.system.health()
assert "credits:submit" in health.scopes, "The key can't submit credit applications"
except AuthError as e:
raise SystemExit(f"Invalid Compense key: {e.status} {e.detail} (trace {e.trace_id})")import { AuthError } from "@compense/sdk";
try {
const health = await client.system.health();
if (!health.scopes.includes("credits:submit")) throw new Error("The key can't submit credit applications");
} catch (err) {
if (err instanceof AuthError) {
console.error(`Invalid key: ${err.status} ${err.detail} (trace ${err.traceId})`);
process.exit(1);
}
throw err;
}use Compense\Sdk\Exception\AuthException;
try {
$health = $client->system->health();
if (!in_array('credits:submit', $health->scopes, true)) {
throw new RuntimeException("The key can't submit credit applications");
}
} catch (AuthException $e) {
exit("Invalid key: {$e->status} {$e->detail} (trace {$e->traceId})\n");
}try
{
GatewayHealth health = await client.System.HealthAsync();
if (!health.Scopes.Contains("credits:submit"))
throw new InvalidOperationException("The key can't submit credit applications");
}
catch (AuthException e)
{
Console.Error.WriteLine($"Invalid key: {e.Status} {e.Detail} (trace {e.TraceId})");
throw;
}health, err := client.System.Health(ctx)
if errors.Is(err, compense.ErrAuth) {
var apiErr *compense.APIError
errors.As(err, &apiErr)
log.Fatalf("invalid key: %d %s (trace %s)", apiErr.StatusCode, apiErr.Detail, apiErr.TraceID)
} else if err != nil {
log.Fatal(err)
}
if !slices.Contains(health.Scopes, "credits:submit") {
log.Fatal("the key can't submit credit applications")
}curl -i https://sandbox.dev.compense.co/gw/v1/gateway/health/ -H "X-API-Key: $COMPENSE_API_KEY"
# HTTP/2 403
# content-type: application/problem+json
# x-request-id: 9f1c2e7a4b8d4e6fa1c0d2e3f4a5b6c7
# {"type":"about:blank","title":"Forbidden","status":403,"detail":"…","trace_id":"9f1c2e7a…"}401 vs. 403 #
| Code | Cause | What to do |
|---|---|---|
401 | The X-API-Key header is missing, or the high-value signature is missing or doesn’t match. | Make sure your HTTP client (or a proxy) isn’t stripping the header. |
403 | Invalid, revoked, suspended or expired key; missing scope; module not contracted; IP not on the allowlist. | Check detail; compare the scopes with system.health(); give the trace_id to support. |
In the SDKs both map to the same exception: AuthError (Python, JS), AuthException (PHP, .NET) and
compense.ErrAuth / compense.IsAuthError (Go). Don’t retry a 401/403: it won’t fix itself.
Signing high-value requests #
A key can have request signing enabled for the high-value scopes
(credits:decide, credits:disburse, subsidies:decide, subsidies:disburse). Those
requests must then carry, in addition to the key:
X-Compense-Signature: t=<unix>,v1=<hex>where <hex> is HMAC-SHA256(signing_secret, "<t>.<raw body>") in hexadecimal; the gateway
tolerates up to 5 minutes of clock skew and compares in constant time.
The 1.1.0 SDKs don't sign requests
None of the five SDKs generates X-Compense-Signature. If your key has signing enabled,
add the header with your own HTTP client (WithHTTPClient in Go, an HttpMessageHandler in
.NET, a custom fetch in JS, a decorated PSR-18 client in PHP) or use plain HTTP. Signing is
optional and disabled by default: check with Compense whether your key requires it.
BODY='{"decision":"APPROVE","approved_amount":"3500000"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$COMPENSE_SIGNING_SECRET" -hex | sed 's/^.* //')
curl -X POST "https://sandbox.dev.compense.co/gw/v1/gateway/credits/applications/$APP_ID/decision/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-decision-$APP_ID" \
-H "X-Compense-Signature: t=$T,v1=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"Best practices #
- One key per system and per environment, with the minimum set of scopes.
- Store keys in a secrets manager and never in code, the repository, a mobile app or a browser.
- If your infrastructure reaches the Internet through fixed IPs, request an IP allowlist for your key.
- Log the
trace_idof every error: it’s the first thing support will ask you for.