# 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](/docs/api/control).
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](/docs/sources/access#expiry-and-revocation).

### Mint a key with curl

```bash
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:

```json
{
  "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.

```ts
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](/docs/credentials#per-member-credentials) 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](/docs/mcp).

## Related

- [Control API](/docs/api/control)
- [Access](/docs/sources/access)
- [Understand admission errors](/docs/knowledge-base/admission-errors)
