Skadi Partner API Console

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

ModePick 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:

HeaderValue
X-API-KeyYour raw API key.
X-TimestampCurrent Unix seconds. Must be within ±5 min of server time.
X-Signaturesha256=<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.