Refresh access tokens before they expire
Problem
Every new access key is a signed token (sv_at_…) with an expiry. The longest
a key can live is 7 days, so a script, CI job, or long-running integration that
calls a source needs a way to get a new key before the old one runs out.
Pasting a key from the dashboard works for a quick test, but it stops working
within a day.
Fix
Give the integration a Control API token instead of an access key. The integration uses it to mint its own access key, reuses that key, and mints a new one before it expires.
- Create a Control API token. Open Organization settings → API
tokens, create a token, and store it as a secret in the integration's
environment (for example
NTHBOUNCER_CONTROL_TOKEN). It never admits traffic on the proxy; the integration uses it only to mint keys. It expires after 1 month, 6 months, or 1 year, so put its replacement on your calendar. See Control API. - Mint an access key with
POST /api/v1/sources/{source}/keys. - Cache the key in memory (or in your secret store) and send it on every request. Do not mint a key per request.
- Mint a new key once about 80% of its lifetime has passed. A 24-hour key
is refreshed after about 19 hours, a 1-hour key after about 48 minutes. The
old key keeps working until its own
expires_at, so requests in flight during the switch are not affected. - Treat
401 access token expiredas "mint again". If a request fails with it (a machine slept past the refresh time, for example), mint a new key and retry the request once.
You do not need to revoke the old key. It expires on its own. Revoke a key only if it leaked; see Access.
Mint a key with curl
curl -sS -X POST "https://swarmvia.com/api/v1/sources/$SOURCE_SUBDOMAIN/keys" \
-H "Authorization: Bearer $NTHBOUNCER_CONTROL_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"label":"orders-sync","access":"read","expires_preset":"24h"}'expires_preset is 1h, 24h (the default), or 7d. The response is 201
and shows the key once:
{
"data": {
"id": 4821,
"label": "orders-sync",
"last_four": "Q2xk",
"access": "read",
"allowed_methods": ["GET", "HEAD", "OPTIONS"],
"expires_at": "2026-10-19T14:05:00+00:00",
"revoked_at": null,
"created_at": "2026-10-18T14:05:00+00:00",
"key": "sv_at_eyJhbGciOiJFZERTQSIs…"
}
}Send data.key in X-Swarmvia-Key, the same as any access key.
Cache and refresh in TypeScript
This works in Node 18 or later. It mints on first use, refreshes after 80% of
the key's lifetime, and mints again once if swarmvia refuses the key as
expired or revoked. Any other 401 (from your origin, for example) is
returned as is, so a failing request never mints a key of its own.
const CONTROL_API = "https://swarmvia.com/api/v1";
const SOURCE = "ns-prod";
const ENDPOINT = `https://${SOURCE}.swarmvia.cloud`;
let cached: { key: string; refreshAt: number } | null = null;
async function accessKey(): Promise<string> {
if (cached && Date.now() < cached.refreshAt) return cached.key;
const res = await fetch(`${CONTROL_API}/sources/${SOURCE}/keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NTHBOUNCER_CONTROL_TOKEN}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ label: "orders-sync", access: "read", expires_preset: "24h" }),
});
if (!res.ok) throw new Error(`mint failed: ${res.status}`);
const { data } = await res.json();
// Both times come from swarmvia, so clock skew on this machine does not matter.
const lifetime = Date.parse(data.expires_at) - Date.parse(data.created_at);
cached = { key: data.key, refreshAt: Date.now() + lifetime * 0.8 };
return data.key;
}
export async function callSource(path: string): Promise<Response> {
const send = async () =>
fetch(`${ENDPOINT}${path}`, { headers: { "X-Swarmvia-Key": await accessKey() } });
const res = await send();
if (res.status !== 401) return res;
const { message } = await res.clone().json().catch(() => ({}));
if (message !== "access token expired" && message !== "access token revoked") return res;
cached = null; // mint a new key and retry once
return send();
}Integrations that only send a fixed header
Some tools only let you paste a static header value: a connector setting, a webhook config, an iPaaS field. They cannot call the Control API themselves.
- Mint a
7dkey with the curl request above. - Paste it into the tool.
- Replace it on a schedule, before 7 days are up. A weekly job is too late; run it every 5 days. A CI scheduled workflow or cron job that mints a new key and writes it to the tool's settings API or your secret store works well.
There is no key that never expires. If the scheduled job stops running, the
tool's requests start failing with 401 access token expired 7 days after the
last refresh, about two days after the missed one.
Notes
- Keys minted with the Control API are machine keys unless you send
"for_me": true, which signs the key for the user who owns the Control API token. A source whose credential is per member needs that; a machine key gets403 credential_requires_member_key. - Each mint is recorded as a key on the source's Access tab, labeled with
the
labelyou send. Use a label that names the integration so its traffic is easy to find in Logs and Metrics. - The dashboard's Create test key form makes keys for curl and quick checks only: 1 hour or 24 hours.
- Agents connected over MCP get their keys from
start_source_sessionand do not need a Control API token. See MCP server.