Authentication
Two modes: HMAC and OAuth. Every partner-facing endpoint accepts either a signed request (HMAC mode) or a short-lived bearer token (OAuth mode).
HMAC is preferred — it binds the signature to the request body and a fresh
timestamp, so replays and man-in-the-middle are ineffective even if TLS is
somehow compromised. Keys marked require_hmac refuse bearer
tokens entirely.
Choosing a mode
| Mode | Pick it when |
|---|---|
| HMAC | Your service can compute SHA-256 HMACs. Binds to body + timestamp.
Required for keys with require_hmac=true. |
| OAuth Bearer | One-off CLI / notebook use. Not accepted for keys pinned to HMAC. |
HMAC: the default
Three headers are required on every request:
| Header | Value |
|---|---|
X-API-Key | Your raw API key. |
X-Timestamp | Current Unix seconds. Must be within ±5 min of server time. |
X-Signature | sha256=<hex HMAC-SHA256(secret, "{ts}.{rawBody}")> |
For GET requests the raw body is an empty string, so the canonical message
is "{ts}." (the dot is required).
Signature playground
The interactive signature playground lives in the developer portal — edit
any input and the canonical string and signature recompute live in your
browser using the Web Crypto API. Paste in your own values to debug a
failing signature.
OAuth 2.0 Client Credentials
Exchange your key for a 1-hour HS256 JWT via POST /oauth-token
(RFC 6749 §4.4). Bearer tokens are convenient for short-lived automation
but do not bind to a specific request — prefer HMAC for
long-running services.
Exchange for a bearer token
cURL
curl -X POST https://zsznsjvcluslttkxjhng.supabase.co/functions/v1/oauth-token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$SKADI_API_KEY_ID" \
-d "client_secret=$SKADI_API_KEY" \
-d "scope=rate appetite"
Node
const r = await fetch("https://zsznsjvcluslttkxjhng.supabase.co/functions/v1/oauth-token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.SKADI_API_KEY_ID,
client_secret: process.env.SKADI_API_KEY,
scope: "rate appetite",
}),
});
const { access_token } = await r.json();
Python
import os, requests
r = requests.post(
"https://zsznsjvcluslttkxjhng.supabase.co/functions/v1/oauth-token",
data={
"grant_type": "client_credentials",
"client_id": os.environ["SKADI_API_KEY_ID"],
"client_secret": os.environ["SKADI_API_KEY"],
"scope": "rate appetite",
},
)
token = r.json()["access_token"]
Token lifecycle
Tokens are 1-hour HS256 JWTs. Client Credentials grant does
not issue refresh tokens (RFC 6749 §4.4) — to "refresh"
you re-exchange your client_id + client_secret
for a new access token. So the job isn't storing a refresh token; it's
knowing when to re-exchange.
The production pattern is lazy cache + 60-second pre-expiry
refresh, with a one-shot retry on
401 expired-credentials as a safety net. Cache the token plus
its absolute expiry (Date.now() + expires_in × 1000) scoped to
your HTTP client instance — never hardcode a token, and don't re-exchange
on every request. The snippets below are the minimum wrapper that's safe to
paste into a service.
Auto-refreshing bearer wrapper
Shell
# In shell scripts, just re-exchange when you need to. 60-second buffer
# means you won't mid-request expire in most cases. For anything long-lived,
# use one of the SDK-style wrappers in the Node/Python samples below.
EXPIRES_AT=0
ACCESS_TOKEN=""
skadi_token() {
local now=$(date +%s)
if [ -z "$ACCESS_TOKEN" ] || [ $((EXPIRES_AT - 60)) -le "$now" ]; then
local resp=$(curl -s -X POST \
https://zsznsjvcluslttkxjhng.supabase.co/functions/v1/oauth-token \
-d "grant_type=client_credentials" \
-d "client_id=$SKADI_API_KEY_ID" \
-d "client_secret=$SKADI_API_KEY")
ACCESS_TOKEN=$(echo "$resp" | jq -r .access_token)
local ttl=$(echo "$resp" | jq -r .expires_in)
EXPIRES_AT=$((now + ttl))
fi
echo "$ACCESS_TOKEN"
}
# Usage: curl -H "Authorization: Bearer $(skadi_token)" ...
Node
// skadiClient.js — minimal auto-refreshing OAuth client (~40 LoC).
// Covers pattern 1 (lazy cache + 60s buffer) and pattern 2 (retry-on-401).
const BASE = "https://zsznsjvcluslttkxjhng.supabase.co/functions/v1";
let cached = null; // { token, expiresAt }
async function getToken() {
const now = Date.now();
if (cached && cached.expiresAt > now + 60_000) return cached.token;
const r = await fetch(BASE + "/oauth-token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.SKADI_API_KEY_ID,
client_secret: process.env.SKADI_API_KEY,
}),
});
if (!r.ok) throw new Error("Token exchange failed: " + r.status);
const j = await r.json();
cached = { token: j.access_token, expiresAt: now + j.expires_in * 1000 };
return cached.token;
}
export async function skadiFetch(path, init = {}, retried = false) {
const token = await getToken();
const headers = { ...(init.headers || {}), Authorization: `Bearer ${token}` };
const r = await fetch(BASE + path, { ...init, headers });
// Pattern 2: one retry if server says our cached token expired.
if (r.status === 401 && !retried) {
const body = await r.clone().json().catch(() => ({}));
if (body.type?.endsWith("/expired-credentials")) {
cached = null; // force fresh fetch
return skadiFetch(path, init, true);
}
}
return r;
}
// Use it like fetch:
// const r = await skadiFetch("/appetite-check?naics=236220&state=TX&line=gl");
// const j = await r.json();
Python
# skadi_client.py — minimal auto-refreshing OAuth client.
# Pattern 1 (lazy cache + 60s buffer) + pattern 2 (retry-on-401).
import os, time, requests
BASE = "https://zsznsjvcluslttkxjhng.supabase.co/functions/v1"
_cached = {"token": None, "expires_at": 0}
def _get_token():
now = time.time()
if _cached["token"] and _cached["expires_at"] > now + 60:
return _cached["token"]
r = requests.post(
BASE + "/oauth-token",
data={
"grant_type": "client_credentials",
"client_id": os.environ["SKADI_API_KEY_ID"],
"client_secret": os.environ["SKADI_API_KEY"],
},
timeout=10,
)
r.raise_for_status()
j = r.json()
_cached["token"] = j["access_token"]
_cached["expires_at"] = now + j["expires_in"]
return _cached["token"]
def skadi_request(method, path, *, _retried=False, **kwargs):
headers = kwargs.pop("headers", {}) | {"Authorization": f"Bearer {_get_token()}"}
r = requests.request(method, BASE + path, headers=headers, **kwargs)
# Pattern 2: one retry on server-reported expiry.
if r.status_code == 401 and not _retried:
try:
if r.json().get("type", "").endswith("/expired-credentials"):
_cached["token"] = None
return skadi_request(method, path, _retried=True, **kwargs)
except ValueError:
pass
return r
# r = skadi_request("GET", "/appetite-check",
# params={"naics": "236220", "state": "TX", "line": "gl"})
# print(r.json())
The Postman OAuth collection already does pattern 1.
Import
Skadi-Partner-API-OAuth.postman_collection.json
and every request auto-fetches and caches a token with the same 60 s
buffer. Look at the collection-level pre-request script if you want to see
the exact logic.
What these snippets don't cover. Multi-instance
deployments where you want exactly one token fetch per expiry window across
N replicas — put the cached token in Redis/Memcached with a SET-NX lease
(only one instance does the exchange, the rest read). And zero-latency
double-buffered refresh, where you fetch the next token in parallel once
the current one hits 75 % TTL — overkill for most partners; worth it
for critical-path services that can't tolerate even a 200 ms exchange.
Sandbox and production isolation
Sandbox and production keys are issued and rotated
independently. They don't share signing material, so a compromise
of your sandbox X-API-Key or hmac_secret cannot
be replayed against production, and vice versa. The same property holds for
the platform credentials Skadi uses internally to operate each environment
— each project carries its own non-overlapping set of API keys, so a leak
in one tenant can't escalate to another.
Practically:
- Treat sandbox and production keys as separate secrets. Don't copy values between them.
- If you suspect either is compromised, rotate that environment alone — no cross-environment coordination needed.
- Sandbox secrets in CI / local dev can (and should) rotate on a different cadence from production.
Skip the scripting
Postman collections for both modes are ready to import — fill two env vars,
run:
HMAC (.json) ·
OAuth (.json) ·
Env (.json).
The signing walkthrough on Getting started
covers the same flow in raw curl.
Codegen-ready: the full OpenAPI 3.1 spec documents both security schemes —
pipe openapi.yaml through
openapi-generator for typed clients.