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.

  1. 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.
  2. Mint an access key with POST /api/v1/sources/{source}/keys.
  3. Cache the key in memory (or in your secret store) and send it on every request. Do not mint a key per request.
  4. 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.
  5. Treat 401 access token expired as "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 7d key 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 gets 403 credential_requires_member_key.
  • Each mint is recorded as a key on the source's Access tab, labeled with the label you 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_session and do not need a Control API token. See MCP server.

Related