",
error_description}`. The authentication layer, one middleware earlier on the
same request, sent `{error: "Unauthorized", code, reason}` — the inversion. The
MCP audience guard sent a third reading in which `error` was a code that was not
the `code`. Nothing in the response said which layer had refused, so no client
could read `error` without shape-sniffing, and this page's own guidance was to
read the `X-Auth-Failure` header instead of the body.
`error` and `code` now always carry the same identifier, so **a client that
reads either keeps working**. What changes:
* **`reason` is gone** from authentication and audience-guard refusals. Its
string is now `error_description`, the field the pipeline refusals already
used.
* **`error` on an authentication refusal is the code** (`expired`,
`ip_cidr_mismatch`, …) rather than the word `Unauthorized` or `Forbidden`.
`X-Auth-Failure` is unchanged and still carries the same value.
* The `/v1` **management** envelope (`{error: {type, message, request_id}}`) and
the `/v1/mcp` **JSON-RPC** error object are deliberately untouched — different
planes, different protocols. The mapping is on the
[errors page](/ai-gateway/errors).
### A 429 now says when to come back
**New.** `budget_exceeded` and `token_scope_limit_exceeded` carry `Retry-After`,
and the number is exact rather than a heuristic: spend counters are keyed by UTC
day and UTC month, so the header counts to that cap's actual rollover. Both data
planes emit it — `/v1/ai` and the JSON-RPC `/v1/mcp` — because it rides on the
shared refusal rather than on either plane's response code.
Where the server cannot know the answer it sends **no header** rather than a
guess. So a 429 without `Retry-After` means "back off on your own schedule", and
the header is never `0` and never fractional.
### A stream that dies mid-flight says why
**New.** A streamed call commits its status line with the first frame, so until
now a provider reset, an early close or a detector fault reached you as a stream
that simply *stopped* — the same clean EOF as a model that finished, with no code
and nothing to retry on. An SSE response that fails now ends with a terminal
frame carrying the same object as any other refusal:
```
event: error
data: {"error":"upstream_stream_failed","error_description":"…","code":"upstream_stream_failed","request_id":"…"}
```
A stream that ends with neither this frame nor the provider's own terminal event
is a transport truncation — retry it.
### The buffered 502 stopped echoing internal errors ⚠ Behaviour change
**Fixed.** A failure in the buffered response path answered with the thrown
error's own message. That path wraps the response detector stack, detokenization,
usage accounting and a database INSERT, so a Postgres failure put connection
strings, role names and column layout in a response reachable by anyone holding a
phantom token — the exact leak the pipeline's own error boundary refuses by name
forty lines below it. It now answers the generic sentence plus `request_id`; the
detail goes to the operator's logs. Quote the request id.
### Usage & Cost tells the truth about what it cannot price
**Fixed — read this if you have been reading the total.** The Usage tab used to
add up its numbers in your browser. A model that is not in KnoxCall's pricebook
records a cost of **\$0** with `cost_source: unpriced`, so that sum reported a
confidently wrong bill: the total was an undercount and nothing said so.
The page now warns when any request in the period could not be priced, **names
the models**, and flags the rows that carry them. It also says the part that
costs money: an unpriced model is the same reason a per-Agent budget set to
`block` does not stop spend on it.
### Group usage by agent, provider or a cost-centre tag — and export it
**New.** The breakdown is grouped **server-side**, on every dimension the FinOps
exporter has had all along: person, team, agent, model, provider, or
`tag:` from your Agents' cost-attribution tags. **CSV export** downloads
exactly the cut on screen, from the same aggregator — so the file and the table
cannot disagree.
Two more things that were being computed and thrown away: people and teams are
now shown by **name** rather than as a raw uuid, and a **cost-per-day chart**
sits above the breakdown.
### Federate CI with OIDC, from the product
**New.** Gateway → **Federation** creates and lists OIDC workload bindings, so a
CI job exchanges its own id\_token for a short-lived KnoxCall capability token and
you store no long-lived secret in your CI provider. Presets for GitHub Actions,
GitLab CI, CircleCI and HCP Terraform fill in the issuer *and* the claim that
issuer is identified by, and there is a copyable GitHub Actions snippet.
The API has accepted these since PR-A5; there was no way to create or even see
one without calling it by hand.
Two refusals the form makes before the server does, because both are easier to
fix while you are still looking at the field:
* **A scope that narrows nothing is refused.** An absent list is read as *no
restriction*, so a binding with an empty scope would mint a token reaching
every provider credential you have configured, on any model, with any tool,
with no cost cap. Name at least one allowlist or one cap.
* **Conditions must pin the claim that identifies your workload.** On GitHub's
issuer, `ref: refs/heads/main` is true for every repository on GitHub.
Bindings are **tenant-wide** — the same list appears under every Gateway, and
deleting one deletes it everywhere. The page says so.
### A token parked by impossible-travel detection now says so
**Fixed.** When a Gateway's impossible-travel setting is `block` and a capability
token is used from somewhere it could not have travelled to, KnoxCall parks it:
its next call is refused until the holder re-authenticates. The token page did
not show that. It looked healthy while every call it made came back 401
`step_up_required`, and the endpoint that clears the flag had existed all along.
The page now shows a banner naming what tripped it and when, the recent security
events behind it, and a **Clear step-up flag** button for a confirmed false
positive — a shared machine key used from two regions, say. Clearing is audited
against your account, and the confirmation says so: if the flag was a true
positive, that record is who let the token back in.
**`block` never revokes**, and the setting now says that too. A revoked token
would turn a false-positive geolocation into an outage; a parked one costs its
holder a re-authentication — which is exactly what someone holding only the
stolen bearer value cannot do.
### Impossible-travel detection is reachable in the UI
**Fixed.** `geo_velocity_action` is on Gateway → Settings (`off` / `warn` /
`block`). The column and its validation both shipped earlier and nothing in the
admin UI ever sent it, so every Gateway sat on the `off` default and the control
could not be turned on without calling the API by hand.
### A Requests tab, and the protection counters on Usage
**New.** Gateway → **Requests** is a per-request activity feed: when, which
agent and model, the status, the latency, the tokens, the cost — and, for the
first time on any screen, what the gateway's own controls did to that request.
A blocked prompt, a request whose PII was tokenized, a response served from
cache: each is visible as such, filterable, and linkable (the filter is in the
URL).
Gateway → **Usage** grows a Protection row above the breakdown: PII spans
redacted, prompts blocked, cache hits and the estimated saving. These numbers
were being recorded on every request and shown nowhere.
Two details that are easy to get wrong and are not:
* **Blocked prompts are counted from firewall events, not from metered
requests.** A blocked request never reaches cost accounting, so it has no
usage row at all — counting there would report the one number that matters as
zero.
* **An unpriced model reads as `unpriced`, never as `$0.0000`.** A model KnoxCall
cannot price is a gap in the pricebook, not a free request — and the same gap
is why a `block` budget does not bind on that model.
**Aggregate and redacted, deliberately.** No prompt or response body reaches this
view, and none is returned by the API behind it. These rows describe traffic that
has already been through PII tokenization; a body here would be the one place in
the product where the values the gateway exists to withhold come back. What a
detector found is shown as its entity type (`EMAIL_ADDRESS`), never as the value.
Paging is keyset on the request ledger, so pages never overlap or skip while new
requests are still arriving.
### See what the response cache is actually doing, and clear it
**New.** Agent → **Budgets & cache** now shows the cache's own numbers over the
last 30 days: hit rate, exact hits, near hits, misses, bypassed requests, how
many entries are stored and how large they are — plus a **Purge cache** button
for the moment you change a system prompt or a model and a replayed answer would
be stale.
Three things it is careful about:
* **A hit rate of nothing is not a hit rate of zero.** With no eligible traffic
the panel says so, rather than printing 0% and sending you to debug a cache
that has had no chance to work.
* **The saving is an estimate, and says so.** A request served from cache never
reached a provider, so what it would have cost cannot be known. The figure is
this Agent's mean priced request cost over the same window times the number of
hits, and it ignores models KnoxCall cannot price.
* **A silent degrade is named.** An Agent set to `semantic` whose every hit was
an exact one is told: semantic matching additionally needs an embedding model,
a conversation id on the request, and reversible tokenization off in both
directions, and without them it quietly falls back to exact matching. That was
previously invisible.
The counters come from the request ledger, not from the entry table — an entry
row only knows it was stored, while the ledger records what happened to each
request.
### Set up Cursor, Claude Code, Cline or Continue from the Agent page
**New.** The Agent detail page has a **Connect** tab. Pick the tool you use, and
KnoxCall mints a capability key scoped to that Agent and renders the exact
files, environment variables and commands to apply — including the verification
command, so you can prove the first call went through the gateway before you
change anything else.
Ten setup packs ship today: Cursor, Claude Code, Cline, Continue, the Anthropic
and OpenAI SDKs for Node and Python, and two generic Anthropic- and
OpenAI-compatible profiles. Which ones are marked **recommended** comes from the
Agent's own provider, not from the name of its model.
This is also the first supported way to obtain a **DPoP-bound** setup. The mint
APIs have accepted `dpop_required` since they were written, and nothing
generated the key pair a client needs in order to send a proof; the render
endpoint generates one and writes it into the tool's own config. KnoxCall keeps
the public half and the thumbprint — never the private key. Binding is opt-in
and separate, because a token bound to a key pair the client cannot use is a
token that 401s on every call: tools with no DPoP install path do not offer it,
and the server refuses it for them.
The rendered files carry the key in plaintext, so the request is a POST (never a
GET, which would leave a live credential in browser history and in every proxy
access log on the way), it is refused to platform operators who are not members
of your tenant, and each render is audited. The key is shown once.
### Connection snippets are composed by the server
**Fixed.** The env-var, curl and SDK snippets on the Agent page were built in
your browser from a second copy of KnoxCall's provider table. The copy was held
byte-identical to the real one by a CI guard, so it never drifted — but two
copies of one table is one copy too many, and the browser had no business
knowing the shape of a Bedrock invoke path. `GET /admin/ai-gateway/{gateway}/agents/{agent}`
now returns `snippets`, built by the same module the quickstart uses, and the
page renders them. No behaviour changes for you: the same bytes, from one place.
The token in those snippets is a placeholder — the GET mints nothing. It is
replaced with the real value only in the moment you issue a key, and only on
that screen.
***
## 2026-09-07
### `knoxcall ai` is a working golden path, in every SDK
**New.** The `ai` command group grew from one sub-command to six, and the new
five are in **all five** SDK CLIs:
```bash theme={"dark"}
export ANTHROPIC_API_KEY=sk-ant-...
knoxcall ai create-agent --name copilot --slug copilot --provider anthropic --secret-from-env ANTHROPIC_API_KEY
knoxcall ai mint --agent
```
Two commands from an empty tenant to a capability token and an `agent_url` you
can `curl`. `create-agent` escrows the key, picks or creates the gateway, and
prints the command that follows; `gateways`, `agents` and `usage` fill in the
rest. Everything takes flags, never positionals, so the surface is identical in
all five languages.
Three things it deliberately will not do:
* **Take a provider key as a flag.** `--secret-from-env NAME` names an
environment variable; there is no `--secret-value`. An argv value lands in
shell history, `ps` output and the CI log line that echoes the command.
* **Create an agent with no upstream.** The API accepts that and stores an agent
whose first call fails; `create-agent` refuses instead, and says why.
* **Guess which gateway you meant.** With several and no `--gateway`, it lists
them and stops.
### `knoxcall ai import` ships in an artifact you can install
**Fixed.** The LiteLLM importer lived in a `cli/` package that was never
published, never in CI and never tested beyond one suite — so
[Migrating from LiteLLM](/ai-gateway/migrate-from-litellm) documented a command
that shipped nowhere. Install it and follow the page and you got
`invalid choice: 'import'`.
It now lives in the Node SDK CLI (`npm i -g @knoxcall/knoxcall`), and the flag is
`--file`:
```bash theme={"dark"}
knoxcall ai import --from litellm --file ./config.yaml # dry run
knoxcall ai import --from litellm --file ./config.yaml --apply
```
`import` is the one `ai` sub-command that is Node-only, on purpose — it carries a
hand-rolled YAML parser because the CLI ships zero runtime dependencies, and four
more hand-ports of that parser would be four more ways for the migration plan to
disagree with itself about your config. The page says so.
### `agent_url` is on every agent response
**New.** `agent_url` — the data-plane base URL,
`https://{tenant}.knoxcall.com/v1/ai/{slug}` — used to come back only from
*create* and from fetching a single agent. It is now on the list rows and on
`PATCH` as well, and every SDK declares it on its agent type.
`PATCH` is the one that mattered: renaming an agent's slug *moves* that URL, and
the response that made the change was the one response that did not tell you the
new one.
### SDKs no longer enumerate the provider catalog
**⚠ Behaviour change, TypeScript and Go only.** The Node SDK's `AIProviderId`
was a six-value union while the server catalog held fourteen, so
`provider: "groq"` was a *type error* for a value the API happily accepts. It is
now `string`, with the current catalog in its doc comment; Go's `AIProviderIDs`
is documented as a convenience list rather than a validation set, and both list
all fourteen. Pass the string and read the server's 400, which names the valid
set — the catalog grows server-side and an SDK-side enum can only ever be behind
it.
If you were pattern-matching exhaustively on `AIProviderId`, that no longer type-
checks; narrow to your own union at your call site.
### The SDK READMEs no longer teach an agent that 502s
**Docs fix, and the one worth reading.** All five SDK READMEs showed a
`createAgent` example with no `provider`/`upstream_secret_id` and no
`primary_route_id`. That call *succeeds* — and produces an agent with no
upstream and no credential template, so the first `/v1/ai` request to it fails.
The copy-paste starting point ended in a 502 on the one call the product exists
to make.
Every README now escrows a Secret first and passes `provider` +
`upstream_secret_id`, and a CI guard reads all five so the snippet cannot regress.
### Your PII recognizers and your scanner now reach `/v1/mcp`
**⚠ Behaviour change.** Two controls that governed `/v1/ai` and quietly did not
govern `/v1/mcp`:
* **Your own PII recognizers.** The MCP plane built one detector stack at boot
and used it for every tenant, so it held the shipped built-ins only. The
custom entity shapes you defined — or that a compliance pack installed — were
redacted out of a chat completion and passed through **verbatim** in a tool
result. An MCP server now takes a `pii_redact_policy_id`, exactly like an
agent, and loads your recognizers per request through the same loader and the
same short-TTL cache.
With a policy attached the plane **fails closed**: if the policy does not
resolve for your tenant, if a stored pattern will not compile, if a
`presidio_*` recognizer cannot run in-process, or if your set was truncated at
the 250 limit, the tool call is refused rather than served under a
partially-applied policy. With no policy attached, a load failure degrades to
the built-ins and logs — because you asked us to enforce nothing in particular.
* **Your guardrail webhook.** An MCP server now takes the same five
`guardrail_webhook_*` fields an agent does. `request` mode is offered the tool
ARGUMENTS after redaction and before they leave; `response` mode is offered the
tool RESULT before it is returned; a `block` verdict refuses the call. Unlike
`/v1/ai` there is no streaming carve-out and no
`guardrail_streaming_unsupported`: the MCP transport materialises every
JSON-RPC message, so both directions are always inspectable.
A `block` arrives as a JSON-RPC error on a 200, not as an HTTP 4xx — the MCP
plane speaks JSON-RPC.
Deleting a PII policy an **MCP server** still references is now a
`409 policy_in_use`, alongside the existing agent rule. The FK is
`ON DELETE SET NULL`, so without it the delete would have silently detached every
bound server and turned their redaction off.
**What we did NOT add, and why.** The MCP plane does not get the
`pii_request_mode` / `pii_response_mode` split the chat plane gained on
2026-09-07. Those exist to fix a specific defect — request-side tokenization
gated on a *response*-side flag — and that defect provably does not exist here:
tool arguments and tool results are inspected by two independent reads of
`pii_inspection`, neither of which consults the other. And `tokenize` /
`detokenize` have no meaning on this plane at all: redaction is irreversible,
there is no conversation and no token map, so a mode named `tokenize` would name
a behaviour that cannot exist. `pii_inspection` stays one boolean because it is
answering one question.
### MCP servers are Live or Test, like everything else
**⚠ Behaviour change.** `ai_gateway_mcp_servers` was the last tenant-owned
resource on either AI-gateway data plane with no Live/Test split. On `/v1/ai` the
mode rides the agent's route and is pinned twice; an MCP server has no route, so
`GET`/`PATCH`/`DELETE` and the data-plane lookup filtered on no mode at all — and
a `kp_test_` capability token reached the **Live** MCP server, at which point
KnoxCall decrypted the **Live** upstream credential and sent it on.
What changes for you:
* Every `/v1/ai-gateway/…/mcp-servers` call is now confined to the calling
request's own space. A production key on `api.knoxcall.com` does not see a Test
server, and a test key on `sandbox.knoxcall.com` does not see a Live one — a
404, not a 403, because the row is not yours to know about.
* `sandbox` appears on every MCP server response. It is **read-only**: it comes
from the mode of the request that created the server. Sending it in a create or
patch body is ignored.
* The same `slug` may now exist in both spaces, so your MCP client's
`connect_url` does not change between Live and Test — which capability token
you present decides which server answers. The uniqueness keys moved from
`(tenant, name)` / `(tenant, slug)` to `(tenant, sandbox, name)` /
`(tenant, sandbox, slug)`, so a test key creating `github` no longer collides
with a Live `github` it cannot even see.
* A `kp_test_` capability token against a Live MCP server is a 404. A request
that reaches the MCP plane with no proven env at all is a 401 — "unknown" never
resolves to Live.
**Every existing MCP server is Live.** They were created before the partition
existed and their `auth` references resolve Live secrets; nothing was silently
moved into Test. If you want a Test twin, create it with a test API key on the
sandbox host — the same slug is fine.
### A gateway budget is now a control, and it reaches `/v1/mcp`
**⚠ Behaviour change.** `budget_daily_usd` and `budget_monthly_usd` on a
**gateway** were writable from the console and the API since the gateway existed
and were read by nothing. A gateway capped at \$50/day enforced no cap at all.
They are enforced now, on **both** data planes:
* `/v1/ai` answers `429 budget_exceeded` with the message prefixed
`Gateway budget:` so you can tell it apart from the agent's own cap.
* `/v1/mcp` refuses `tools/call` with the same reason. Discovery methods
(`initialize`, `ping`, `tools/list`) are deliberately still served: a client
refused at the handshake reports "could not connect" and you never see the
cap that caused it.
The new field is `budget_overage_action` on a gateway: `block` (refuse) or
`warn` (serve and emit `X-Knox-AI-Budget-Pct` / `X-Knox-AI-Budget-Warning`).
There is no `fallback` — that action swaps an *agent's* route and model, which a
gateway does not have.
**Existing gateways that already carried a budget were set to `warn`**, not
`block`. Those numbers were typed in while the column enforced nothing, and
turning them into a hard refusal during a deploy would be an outage we chose on
your behalf. New gateways default to `block`. One PATCH turns an existing one on.
The MCP plane also gained the H-32 refusal it was missing: a capability token
carrying `max_cost_per_day_usd` or `max_tokens_per_day` is now **refused** at
`/v1/mcp` rather than served. A tool call costs nothing and consumes no model
tokens, so that counter would never move — serving it would grant unlimited use
of a credential whose own signed grant says it is capped.
### A hard spend cap refuses a model nothing can price
**⚠ Behaviour change.** An agent with `budget_overage_action: "block"` and a
dollar cap is now refused with `403 budget_unpriced_model` when its model has no
price in KnoxCall's pricebook.
The cap could not bind on such a model: the call is metered at \$0, the counter
never moves, and the dashboard shows the budget comfortably unspent while spend
continues. That is permanent for `openai-compatible`, `azure-openai` and
`ollama`, whose endpoints are yours and therefore structurally unpriceable.
`warn` and `fallback` agents are unaffected — they never promised a hard stop.
If you need `block` on an unpriceable provider, either add the model to the
pricebook or switch the action to `warn` and alert on
`cost_source = 'unpriced'` in your usage export.
### A leaked system-prompt canary is blocked, not just noted
**⚠ Behaviour change.** When a firewall policy is set to `block` and the
provider's response echoes the system-prompt canary — proof that the prompt in
that request extracted your system prompt — the response is now refused with
`400 canary_leak` instead of being served with a 200.
The marker itself never reached you either way; that part was already correct.
What was wrong was the verdict: `ai_gateway_usage.firewall_outcome` recorded
`warn` for a confirmed extraction, so a firewall report said the control held.
Both the buffered and the streaming path now record the policy's real action. On
a streamed response the bytes have already left when the leak is detected, so
there the change is the record and the alert, not a refusal.
Policies set to `warn` are unchanged.
### PII policies and recognizers are on the API
**New.** `/v1/ai-gateway/pii-policies` and `/v1/ai-gateway/pii-recognizers` now
carry full CRUD, plus a side-effect-free pattern tester at
`POST /v1/ai-gateway/pii-recognizers/test`. They are in all five SDKs, the
Postman collection and the OpenAPI spec.
Why it matters: `pii_redact_policy_id` has always been accepted on
`POST …/agents`, but the only way to *obtain* a policy id was to click through
the console. A HIPAA-shaped setup — define your own entity shapes, bundle them,
attach the bundle to an agent — is now scriptable end to end from an API key.
There is a worked example in [PII redaction](/ai-gateway/pii-redaction).
### Three PII changes that used to succeed quietly are now refused
**⚠ Behaviour change.** All three apply on the console as well as the API,
because both surfaces call the same code.
* **A `recognizer_ids` entry you do not own is a `400 recognizer_not_found`.**
It used to be stored. It was never a cross-tenant read — the data plane
resolves it to nothing — which is exactly the problem: the policy listed three
recognizers and ran zero, and nothing said so until the PII reached the
provider.
* **Deleting a policy an agent still uses is a `409 policy_in_use`.** It used to
succeed and silently detach every bound agent, turning redaction off for each
of them. Detach the policy from the agents first.
* **Deleting a recognizer a policy still lists is a `409 recognizer_in_use`.**
Remove it from each policy first. We refuse rather than dropping the id for
you, because an empty `recognizer_ids` means *every* enabled recognizer — so
quietly removing the last id would widen the policy, not shrink it.
If you script against these, the failures are visible ones: a refusal you can
see beats a policy that looks armed and is not.
### The Presidio analyzer URL goes through the SSRF chokepoint
**⚠ Behaviour change, security.** If you point the gateway at your own
[Microsoft Presidio](/ai-gateway/pii-redaction) sidecar (Settings → Integrations
→ *AI Gateway PII analyzer*), that URL is now validated the way every other
tenant-supplied destination on this platform is: the hostname is resolved, every
returned address is checked, and the request is sent to the address we checked
rather than to whatever the name resolves to a moment later.
Why it matters: the analyzer receives **the prompt text being inspected**. Before
this, the URL was dialled with an unvalidated HTTP client, so a value pointing at
a private or link-local address — `http://169.254.169.254/`, `http://127.0.0.1`,
or a hostname that resolves to one — made the gateway send prompt text there.
What you may notice:
* On the **cloud** service, an analyzer URL must be a publicly-routable
`http(s)` host. Saving a private-network URL now fails with
`presidio_url_blocked`, and an existing row with such a URL stops being used:
the detector stack falls back to its built-in regex/checksum tiers (degraded,
not broken) and logs the refusal.
* On a **self-hosted** deployment, a sidecar on your own LAN is still supported.
Tick **“Analyzer is on this deployment’s private network”** on the same
integration. It is off by default, it is ignored on the cloud service, and it
never permits link-local addresses (`169.254.0.0/16`, `fe80::/10`) — the cloud
metadata range is not a network anyone runs a sidecar on.
* Redirects and non-`http(s)` schemes on the analyzer URL are refused.
### Failed AI and MCP authentications are audited
**Additive.** Every refusal from the gateway's authentication layer now writes an
`ai_gateway.request_denied` row to your audit log, on **both** `/v1/ai` and
`/v1/mcp`. Previously only refusals from inside the request pipeline (firewall,
budget, model policy) were audited, and everything the auth layer rejected —
expired tokens, revoked tokens, a replayed one-shot token, a token used from
outside its `ip_cidrs` or its `valid_window`, a DPoP failure, an MCP token
presented to the wrong resource — produced a status code and no record at all. A
credential-stuffing run against your gateway was invisible in the audit log.
Filter on `details.reason` to see them: `auth_token_rejected`,
`auth_scope_denied`, `auth_tenant_unavailable`, `auth_agent_unavailable`,
`auth_oneshot_replay`, `mcp_audience_denied`. `details.code` carries the exact
failure and `details.plane` is `ai` or `mcp`.
Two deliberate exclusions: a request presenting **no token, or a token that
matches no record**, writes nothing — there is no workspace to attribute it to,
and an unauthenticated request must not be able to write to your audit log. A
**paused agent or gateway** writes nothing either, because that is configuration
state and it would fire on every request for as long as the pause lasts.
### PII redaction is two settings now, and three parallel paths that leaked are closed
**⚠ Behaviour change, security.** The core promise — a detected entity does not
reach the provider — held on the default path and failed on three others. All
three are closed, and the single boolean that caused the first one is now two
explicit settings.
**`pii_request_mode` and `pii_response_mode` replace `pii_detokenize_response`.**
One flag was answering two questions, and the one it named was the wrong one:
request-side tokenization was gated on `pii_detokenize_response`, a **response**
toggle. An agent with a PII policy attached and `pii_detokenize_response: false`
loaded its recognizers, validated them, refused on a degraded policy — and then
sent the prompt to the provider verbatim.
| Setting | Values | Default |
| - | - | - |
| `pii_request_mode` | `tokenize` \| `off` | `tokenize` |
| `pii_response_mode` | `detokenize` \| `redact` | `detokenize` |
Existing agents were migrated to today's response behaviour
(`true` → `detokenize`, `false` → `redact`) and to `pii_request_mode: "tokenize"`
**including the agents that had detokenize off** — those are exactly the ones that
were leaking. If one of your agents deliberately forwarded prompts untouched, set
`pii_request_mode: "off"` on it explicitly. `pii_detokenize_response` is still
accepted on create and update, and is now a read-only mirror of
`pii_response_mode == "detokenize"` on reads; sending both with contradictory
values is a 400.
**Streaming on a shape with no mid-stream redactor is refused.** KnoxCall ships a
hold-back rewriter for the Anthropic, OpenAI and Gemini SSE shapes. Cohere's
native SSE shape has none, and Bedrock streams length-prefixed
`application/vnd.amazon.eventstream` binary frames rather than SSE at all. The
handler used to stream those through untouched — no response-side redaction, no
detokenization, no canary scan, no refusal. A streamed request on a `cohere` or
`bedrock` agent is now **400 `streaming_redaction_unsupported`**. Send it without
`stream: true` (buffered responses are fully redacted), or set
`streaming_enabled: false` on the agent.
**A body the tokenizer cannot read is refused.** `tokenizeRequestBody` returned
the RAW body with a 200 for anything that would not `JSON.parse`. On an agent
with `pii_request_mode: "tokenize"` that is now **400 `pii_unscannable_body`**.
"We found no personal data" and "we could not look" are different answers.
**More of the prompt is scanned.** The shared prompt-shape enumeration the
tokenizer and the firewall both read now covers Cohere `message`, `preamble` and
`chat_history[].message` (on Cohere, `message` *is* the prompt — those agents were
sending every turn upstream in the clear), the OpenAI Responses `instructions`
field, RAG grounding `documents[]`, and **tool descriptions** on all four
declaration shapes. Tool *schemas* are still not rewritten, deliberately.
**A configured Presidio analyzer that cannot run is announced.** `getPresidioDetector`
answered `null` to two different questions — "no analyzer configured" and "an
analyzer IS configured and this deployment will not use it" (URL refused by the
egress check, or circuit breaker open after five consecutive failures). The
second degraded to the Aho-Corasick + regex tiers, served 200, and recorded
nothing anywhere. Now: with a PII policy attached, `503 pii_policy_degraded`;
with no policy, served with `X-Knox-AI-Pii-Degraded: presidio_` and a
`detector_unavailable` row in the PII events ledger. Having no analyzer
configured is still silent, because nothing was asked for.
**Semantic caching follows the new columns.** "Reversible tokenization is
active" is now two columns, and the one that actually mints tokens is
`pii_request_mode`. Either half keeps semantic matching off (exact caching is
unaffected). Because the migration set `pii_request_mode: "tokenize"` on every
tenant-owned agent, an agent that had `pii_detokenize_response: false` **and**
`cache_mode: "semantic"` drops to exact caching. That is the safe direction and
the reason the guard exists: those bodies are full of conversation-scoped `KC_*`
tokens. Set `pii_request_mode: "off"` to get semantic matching back.
See [PII redaction](/ai-gateway/pii-redaction#the-two-directions).
***
## 2026-09-03
### Credentials in a URL are refused
**⚠ Behaviour change.** A `guardrail_webhook_url` on an agent, or an
`upstream_url` on an MCP server, may no longer carry userinfo — `https://user:pass@host/…`
is rejected at create and at update. A credential written into a URL is stored in
plaintext in a column nothing encrypts, and it leaks into every log line, audit
row and error message that echoes the URL. Any userinfo already stored is
stripped from API read responses. Move the credential into a Secret and reference
it from the auth headers instead.
***
## 2026-09-02
### A route requiring a signature is refused, not silently unsigned
**⚠ Behaviour change.** `/v1/ai` cannot compute a rolling URL signature, so an
agent pointed at a route with `require_signature` used to reach the upstream
without one. It is now refused with **503 `route_signature_unsupported`**, and a
failover candidate with the same setting is skipped rather than tried, with the
skip written to the audit row. Same shape as `route_field_actions_unsupported`
(2026-08-27): the plane refuses what it cannot honour rather than serving a
weaker version of it.
### Responses carry KnoxCall's own document-isolation headers
**⚠ Behaviour change.** A response served on a KnoxCall host now carries a
sandboxing `Content-Security-Policy`, `X-Frame-Options: DENY` and
`X-Content-Type-Options: nosniff` written by the gateway — and the **upstream's**
values for those headers are dropped rather than relayed. The body is
tenant-controlled bytes on our origin; the upstream does not get to set the
policy the browser honours there.
If you were reading an upstream's CSP through the gateway, you will now see
KnoxCall's instead.
### Upstream `Set-Cookie` is never relayed
**⚠ Behaviour change.** `Set-Cookie` and `Set-Cookie2` from the upstream are
dropped on every `/v1/ai` path — cache hit, buffered and streamed alike — and the
full RFC 7230 hop-by-hop set is stripped rather than just `transfer-encoding`.
An upstream cannot set a cookie on a KnoxCall origin. Multi-valued response
headers now relay as a list instead of being collapsed.
***
## 2026-09-01
### Phantom tokens move to their own `kp_` prefix
**⚠⚠ Breaking change, no compatibility window. Deployed to production 2026-09-02.**
Phantom tokens — the AI data-plane credential — were `kc_`-prefixed, which is the
*OAuth 2.1 access token* family. They are now **`kp_`**:
```text theme={"dark"}
kp___<32hex>_ e.g. kp_live_a_…, kp_test_t_…
```
**A `kc_`-prefixed phantom token no longer parses. There is no dual-parse window.
Re-mint every AI data-plane token.** A token issued before this change fails at
the edge; the fix is a new mint, and nothing else recovers it.
Why it could not stay: `src/client-api/auth.ts` classifies a bearer credential by
`kc_`, so a phantom token presented to any `/v1` client-API route was parsed as an
OAuth token — which requires four underscore-separated segments where this format
has five — and refused as `401 invalid_token: Malformed access token`. The
credential was valid; it had reached the wrong plane, and the error said nothing
of the sort. Two credential families sharing a prefix is a type-confusion hazard
whether or not a particular caller trips it today.
`kp_` is now disjoint from `kc_` (OAuth access), `kr_` (OAuth refresh), `tk_`
(tenant API key) and `AKE` (enterprise access key), and it has been added to the
never-forward list that stops a KnoxCall credential being relayed to a
third-party MCP server — see [MCP gateway](/ai-gateway/mcp-gateway#passthrough--each-user-acts-as-themselves).
This landed while the AI gateway had no live tenant traffic, which is why no
customer integration is known to have broken. That was luck, not process. This
changelog entry, and the docs-truth guard that now requires one for any change
under `src/ai-gateway`, exist so the next prefix change is announced before it
ships.
***
## 2026-08-31
### Linked-secret domain violations are audited on both tenants
When a shared secret's domain lock refuses an upstream, a
`secret_share.domain_violation` audit row is now written for **both** the tenant
that used the secret and the tenant that shared it — from `/v1/ai` and, newly,
from the MCP plane. The lender could previously not see that their lock had
fired.
### Test-mode PII detections stop firing live compliance alerts
PII detection events now record which data space they came from. A detection in
Test no longer raises the Live HIPAA / SOC 2 compliance alert or consumes its
cooldown, so a sandbox experiment cannot mask a real Live detection. Expect fewer,
truer alerts.
### Federated mints are audited
Every token minted through `/v1/oauth/token` now writes an
`ai_gateway.token_mint` audit row tagged `via: oidc_federation`. This file
previously wrote no audit row of any kind, so a burst of federated data-plane
credentials was invisible to the privileged-mint velocity monitor.
***
## 2026-08-30
### Minted tokens expire by default
**⚠ Behaviour change.** A mint that named no expiry produced a token that
**never expired**. Every mint path now shares one implementation: an omitted
expiry means **30 days**, and any requested TTL is clamped to between **60
seconds** and **90 days**. The default is deliberately not the ceiling — long
enough that a CI key is not a weekly chore, short enough that a key leaked from a
repo scraped last quarter is already dead.
If you rely on a never-expiring AI token, you do not have one any more. Re-mint
with an explicit `expires_in_seconds`, up to the 90-day cap.
### Archiving an agent or gateway disables its orphaned routes
**⚠ Behaviour change.** Archiving left the underlying `routes` rows enabled, so
the upstream stayed reachable through a gateway object you had archived. Archive
now disables the routes it orphans — scoped to the data space you archived in, so
archiving a sandbox agent does not disable the Live route (that scoping was
itself a same-week fix; between 2026-08-30 and 2026-08-31 it was not scoped).
### CSV exports are quoted and formula-neutralized
**⚠ Behaviour change.** Usage-export and compliance-bundle CSVs now quote every
cell and prefix any cell beginning `=`, `+`, `-` or `@` with a single quote, so a
model name or tag cannot execute as a spreadsheet formula when the file is
opened. A strict downstream parser that assumed unquoted cells needs updating.
### A response containing an injected credential is never cached
If a response body contains a resolved injected credential, it is not written to
the response cache. Those calls will never register a cache hit.
### Guardrail webhooks receive a scrubbed body
**⚠ Behaviour change.** The guardrail webhook payload now decompresses the
upstream response before scanning it, removes any resolved secret value found in
it, and sends an **empty** body when the content encoding cannot be decoded — a
body that cannot be inspected is not forwarded to your endpoint. A webhook that
parsed the raw compressed bytes needs updating; see
[Guardrail webhook](/ai-gateway/guardrail-webhook).
***
## 2026-08-28
### Semantic-cache embedding calls are metered
**⚠ Behaviour change, cost.** The semantic cache's embedding calls were real
provider spend and were recorded nowhere. They now produce their own usage rows,
count against the agent's budget, and count against a token's signed daily cost
and token caps. Expect your usage row count and your metered spend to rise on
agents using `cache_mode: "semantic"` — the money was always being spent; only
the accounting changed. A budget can now trip on cache traffic.
Under a signed `max_cost_per_day_usd`, an embedding on a model that is **not in
the pricebook** is not purchased at all — the semantic cache silently degrades to
exact matching rather than making a spend the cap cannot count.
### A suspended workspace is refused on every plane
**⚠ Behaviour change.** A phantom token belonging to a workspace that is not
servable is refused with **403 `tenant_unavailable`** on `/v1/ai` and `/v1/mcp`,
and `/v1/oauth/token` answers `invalid_grant`.
### `Cookie` is never forwarded upstream
**⚠ Behaviour change.** A `Cookie` header on a request to the data plane is
dropped rather than relayed to the provider, and a KnoxCall `Authorization`,
`DPoP` or `x-api-key` credential is consumed by the gateway rather than passed
through. A browser-originated call that relied on its cookies reaching the
upstream will see them gone.
***
## 2026-08-27
### A paused agent or gateway refuses, on both planes
**⚠ Behaviour change.** A paused or archived agent — or one whose gateway is
paused or archived — is refused with **503 `agent_not_active`** /
**`gateway_not_active`** on `/v1/ai`, and, newly, on `/v1/mcp` as JSON-RPC
`-32603`. The MCP plane previously served traffic for a paused gateway.
### A route with Relay field actions is refused
**⚠ Behaviour change.** `/v1/ai` cannot apply a route's Relay field actions, so
an agent pointed at such a route is refused with **503
`route_field_actions_unsupported`** rather than served with those actions
silently skipped. A failover candidate with them is skipped and audited.
### Batch spend counts against your budget
**⚠ Behaviour change.** Batch spend was metered but not *charged*: it appeared in
usage and never moved a budget. It now counts against the agent's budget and the
token's signed daily caps, so a batch can push you into `budget_exceeded` or
`token_scope_limit_exceeded`.
***
## 2026-08-26
### An unenforceable budget refuses rather than serving unmetered
**⚠ Behaviour change.** When the cross-worker spend counter is unusable, the
gateway can no longer know what has been spent. It now sets
**`X-Knox-AI-Budget-Unmetered: 1`** on the response, and on an agent whose
`budget_overage_action` is `block` it refuses with **503
`budget_unenforceable`** instead of serving a request it cannot count. `warn` and
`fallback` agents are still served, unmetered, with `X-Knox-AI-Budget-Pct`
suppressed — a percentage from a frozen counter reads `0.0%`, which is the lie in
header form. A counter holding a non-numeric value now raises instead of reading
as zero.
### DPoP `htu` is derived from the canonical URL
**⚠ Behaviour change.** The `htu` claim in a DPoP proof is now checked against
the canonical public request URL rather than the raw `X-Forwarded-*` / `Host`
headers. Sign your proof against the URL you actually call. A client behind a
proxy that rewrites those headers may need to change what it signs.
***
## 2026-08-25
### Batch spend is metered, once, at the batch rate
Provider batch APIs bill at a discount, and batch requests were being metered at
the interactive rate — or, on some shapes, not at all. Batch usage is now metered
exactly once, at the batch rate. (Charging it against the budget came two days
later; see 2026-08-27.)
### Per-user MCP credential passthrough
An MCP server can be set to `auth.mode: "passthrough"`, where each caller
supplies their own upstream credential per request and KnoxCall stores nothing.
`X-KC-User` is required and is resolved against your directory; a KnoxCall
credential in the header is refused; connections are never shared between users.
See [MCP gateway](/ai-gateway/mcp-gateway#passthrough--each-user-acts-as-themselves).
### A residency denial names the destination, not the pin
A data-residency refusal recorded the agent's configured region rather than the
region the request would have reached, which is the one an auditor needs.
### Extended-thinking and refusal text are redacted
**⚠ Behaviour change, security.** The streaming redactor scanned a model's
`content` and nothing else. Two other fields carry model-visible text and were
going out unscanned:
* **Anthropic `thinking_delta`** — with extended thinking enabled, the model's
reasoning streams in its own delta type, and reasoning quotes the prompt
constantly. An SSN in a thinking block reached the client verbatim **and
produced no detection event**, so it appeared in no evidence row and fired no
canary alert. Because the buffered path scans the whole response body,
`stream: true` was effectively an opt-out from redaction for this content.
* **OpenAI `delta.refusal`** — when a model declines, its explanation streams in
`refusal`, not `content`, and a refusal routinely quotes back what it is
refusing.
Both are now redacted, each in its own hold-back window. See
[what in a stream is scanned](/ai-gateway/streaming-guarantees#what-in-a-stream-is-scanned).
If you replay Anthropic thinking blocks verbatim on a later turn, note that
redacting their text invalidates the block's `signature` — re-prompt instead.
Streamed **tool-call arguments** are still not scanned; that is stated explicitly
on the streaming page rather than left to be discovered.
### MCP sessions are scoped to the presenting token
**⚠ Behaviour change.** The upstream MCP session pool was keyed on the
client-supplied `mcp-session-id` header. Two different phantom tokens sending the
same value — including the common case of sending none at all — shared one
upstream connection. The pool key now leads with the verified token, so **each
token gets its own upstream MCP session**.
What you may notice: slightly more upstream connections, and one extra
initialization handshake the first time each token calls. If your upstream MCP
server keeps per-session state, that state is now per-token rather than shared.
A single token holds at most **4** concurrent sessions per server per environment;
opening a fifth closes the least recently used.
### `server_type: 'collection'` MCP servers are refused at creation
Creating an MCP server backed by a route collection was accepted by the API and
then failed on every call, because the feature was never built. It is now refused
at create with a clear message. An existing collection row answers **501** (it
previously produced a generic 502 that pointed you at an upstream nothing had
dialled).
### `resources/*` and `prompts/*` on the MCP plane are documented as refused
No code change — the gateway governs **tools**, and has always answered
method-not-found (`-32601`) for resource and prompt methods. It was not written
down anywhere. See [MCP gateway](/ai-gateway/mcp-gateway).
***
## 2026-08-24
### Every content block in a streamed response is redacted
**⚠ Behaviour change, security.** The OpenAI streaming redactor inspected
`choices[0]` only. A request with `n > 1` streamed its second and later
completions **unredacted** — the control was running and looking at the wrong
element. Gemini's redactor had the same defect one field over
(`candidates[0].parts[0]`), and additionally missed a candidate's second text
part.
All completions are now extracted and redacted, each with its own hold-back
window. If you use `n > 1` with PII redaction on, expect redaction to appear in
completions where it previously did not.
### `X-KC-Team` is honoured
**⚠ Behaviour change.** Sending `X-KC-Team` previously cleared the team on the
usage row — the opposite of what the header is for — so calls that named a cost
centre were the ones attributed to nobody. The header is now resolved against your
directory and lands on `attributed_team_id`. See
[Budgets & FinOps](/ai-gateway/budgets-finops#the-x-kc-team-override).
### Rate limits on all three data planes
`/v1/ai`, `/v1/mcp` and `/v1/oauth/token` register ahead of the proxy's catch-all
and so had **no request-rate ceiling of any kind**. All three now enforce
cross-worker sliding windows — see [Limits](/ai-gateway/limits#request-rate). The
ceilings are generous abuse floors; a normal integration will not meet one.
### Provider credentials are actually injected
**⚠ Behaviour change, and the big one.** The data plane did not perform route
secret injection at all: every `/v1/ai` upstream call went out with **no provider
credential**, so it 401'd against any real provider. The route's
`inject_headers_json` / `inject_body_json` are now rendered through the same
secret-resolution chokepoint the main proxy uses, on the primary route, every
failover route, the output-schema retry, and the semantic-cache embedder.
If you had worked around this by putting a real provider key in your own client,
stop — the gateway now injects one too and the upstream will see whichever it
resolves.
### Live/Test confinement is enforced
**⚠ Behaviour change.** A phantom token's environment segment (`…_live_` /
`…_test_`) was parsed and discarded, so a Test token drove Live routes. The
segment is now part of the route lookup on the primary and every failover route,
and a token whose environment cannot be proven is refused. A Test token that had
been reaching a Live route will now get a 404 for that agent's route. (Phantom
tokens carried a `kc_` prefix at the time; they moved to `kp_` on 2026-09-01 —
see below.)
### Firewall policies can be created, and `block` is reachable
No product surface could create a row in the firewall-policy table, so with no
policy the evaluator capped every outcome at `warn` — **`block` was unreachable for
every tenant** while the UI presented it as configurable. Policies are now
creatable from `/v1`, the admin API and the gateway-creation seeder, and a `block`
policy blocks. See [Firewall](/ai-gateway/firewall).
### Tenant PII recognizers are read by the data plane
Custom recognizers and compliance-pack recognizers were written and never read.
They now compile into the detector stack on the outbound, streaming and buffered
paths. An attached policy that cannot be resolved, or that holds a pattern that
will not compile, now **refuses the request** rather than forwarding unredacted.
### The MCP control plane exists
`/v1/ai-gateway/.../mcp-servers` CRUD, tool management, an admin twin and an MCP
tab. Before this, creating an MCP server required a raw database insert.
### MCP upstream egress and credentials go through the chokepoints
**Security.** Tenant `upstream_url` values are now resolved-and-pinned like every
other tenant-supplied destination, and upstream auth **must** be a
`{{secret_id:…}}` reference — a cleartext credential in the `auth` column is
refused rather than forwarded.
### Streaming hold-back leaked at the boundary
**⚠ Behaviour change, security.** The hold-back FSM deferred a match ending inside
its tail but emitted everything before that same index, so an entity *straddling*
the boundary had its head emitted in the clear. On a typical one-word-per-frame
stream that was nearly every entity. Separately, patterns that do not match until
complete (JWTs, provider API keys) had their heads emitted at the default window.
Both are fixed; the minimum hold-back window is now 96 characters, raised from
whatever a tenant had configured below it.
### Error boundary and fail-closed policy loads
A database error inside the pipeline used to reject an async middleware promise
that Express 4 ignores — leaving the request open indefinitely. Every path now
answers. A firewall or PII policy that cannot be loaded refuses the request (503)
instead of proceeding with the control off.
### Guardrail quality is measured
A labelled corpus and a recall ratchet now run on every build, so a weakened
detector fails CI. Published figures live in
[PII redaction](/ai-gateway/pii-redaction).
***
## 2026-08-12
### Usage rollup is scheduled
`ai_gateway_usage_daily` had no writer registered anywhere, so it was empty — and
everything reading it (the spend-spike check, console usage) silently saw nothing.
It now runs hourly on a single scheduler process.
***
## 2026-08-08
### H-31 — the response cache is keyed on the caller, not just the conversation
**⚠ Behaviour change, security.** The cache's isolation boundary was
`x-kc-conversation-id`, a plain client header with no binding to the caller's
token. Two users in one tenant sending the same body **with no conversation
header** both scoped to the empty string and could serve each other's responses.
Stored response headers were replayed wholesale, `Set-Cookie` included.
The cache key now folds in the **phantom token id** — the only caller identity the
gateway verifies — and the key prefix moved `v1` → `v2` so nothing written under
the weaker assumption can ever be matched again. A request the gateway cannot
attribute to a token does not read from or write to the cache at all.
What you may notice: **every cache entry written before 2026-08-08 was
abandoned**, so hit rates started from zero that day. Two clients that were
(incorrectly) sharing a cache entry no longer do.
### H-32 — capability-token scope is enforced
**⚠ Behaviour change, security.** A token's `providers`, `models`, `tools`,
`egress_routes`, `max_cost_per_day_usd` and `max_tokens_per_day` were HMAC-signed
and **never checked** — only `valid_window` and `ip_cidrs` were. The admin UI
rendered the unenforced fields as though they were restrictions.
They are now enforced, after model policy resolves. Three fail-open cases inside
the fix were corrected in the same change: a malformed scope **denies** rather
than being ignored; a present-but-empty list is malformed rather than
"unrestricted"; and a cost cap **refuses** a model with no price rather than
metering it as free.
If you minted tokens with scope fields set, expect them to start taking effect —
this is the change most likely to turn a working call into a 403. Check
`X-Auth-Failure` and the `error` code on any new refusal before assuming an outage.
### H-33 — buffered responses get response-side redaction
**⚠ Behaviour change, security.** Non-streaming responses bypassed all
response-side PII redaction and detokenization, so a client could opt out of
redaction simply by omitting `stream: true`. Both branches now run the same stack.
***
## 2026-07-09
### Semantic caching, conversation-scoped
Semantic (embedding-similarity) cache matching was re-enabled with a hard
conversation scope, so an approximate match can only occur within one conversation.
See [Response caching](/ai-gateway/quickstart#response-caching).
### Impossible-travel step-up
`geo_velocity_action = 'block'` flags a token for **step-up** on an anomalous
location change rather than revoking it: the token's next use is refused with
`step_up_required` until it is re-authenticated or the flag is cleared. See
[Tokens & DPoP](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up).
***
## Earlier
The gateway was built as roughly thirty sequential changes through May–July 2026:
the phantom-token format and DPoP binding, the PII detector stack and hold-back
streaming redactor, the prompt firewall and canary tokens, the pricebook, budgets
and FinOps exports, OIDC workload federation at `/v1/oauth/token`, the response
cache, and the MCP gateway. Those predate this changelog; the pages in this
section describe what they do today rather than the order they arrived in.
# Compliance Packs
Source: https://docs.knoxcall.com/ai-gateway/compliance-packs
One-click HIPAA, PCI-DSS, GDPR, and SOC 2 packs that install PII recognizers and audit-log alert rules — with honest install reporting that never silently drops a rule.
# Compliance Packs
A compliance pack installs, in one operation, the recognizers and alert rules for a regulatory regime. Available packs: `hipaa-safe-harbor`, `pci-dss`, `gdpr`, `soc2`.
## What a pack installs
1. **PII recognizers** — entity-type patterns added to the [detector stack](/ai-gateway/pii-redaction) (for example, HIPAA Safe Harbor's 18 identifiers).
2. **Alert rules** — `ai_gateway_event` alerts that fire on specific conditions, including PII-event conditions counted from the `ai_gateway_pii_events` ledger. For example, HIPAA installs a rule for *"an SSN appears in a response"* and a *"canary echoed"* rule.
## Honest install reporting
The installer **never silently drops a rule**. If a pack contains an alert condition the current engine can't express, the install result reports it explicitly in an `alerts_skipped` field rather than quietly discarding it — so what you installed is always exactly what you can see. PII-event conditions (entity type + direction) are translated into real evaluator conditions, so the richer rules install instead of vanishing.
Install a pack from the **Compliance tab** on the gateway detail page, or via the Packs API. A dry-run shows exactly what would be created before you commit.
## Evidence exports
The Compliance tab can produce a **per-request evidence bundle** — what was redacted or blocked, by which policy, for a given period — as an exportable artifact. This is the compliance evidence auditors ask for and that most gateways can't produce.
## Related audit actions
Packs listen on audit actions the gateway already emits, so alerts light up with no extra wiring:
| Audit action | Fires when |
| - | - |
| `ai_gateway.canary_leak` | A [canary token](/ai-gateway/firewall) was echoed by the model. |
| PII-event conditions | A configured entity type appears in a request or response (from the PII ledger). |
Right-to-be-forgotten (RTBF) and retention automation for the PII ledger and token map are part of the compliance roadmap; the recognizer + alert-rule install and evidence export described here are available today.
# Connect Claude Code, Claude Desktop and Cursor
Source: https://docs.knoxcall.com/ai-gateway/connect-mcp-clients
Get an MCP token as a person — not as a CI job — and point your client at a KnoxCall-governed MCP server.
# Connect your MCP client
Every tool call your client makes through KnoxCall reaches the upstream with your
identity on it: the tool allowlist, the prompt-injection firewall and PII
redaction all run first, and the call is recorded against **you** rather than
against a shared key.
This page is the whole flow for the three clients KnoxCall documents. One page
rather than three, because the token flow is identical and only the file location
differs — a page per client is three places for the same instructions to drift.
You copy the config block into place yourself. KnoxCall does not install
anything into your editor and there is no extension or protocol handler.
## 1. Get a token, as yourself
An MCP token has to name a person. The workload-federation exchange
(`/v1/oauth/token`) mints tokens for CI jobs from a signed id\_token, which is the
wrong shape for a human at a laptop — so there is a device flow.
**If your client speaks RFC 8628** (device authorization), point it at KnoxCall
and it will show you a code. **If it does not**, run the flow yourself:
```bash theme={"dark"}
# 1. Ask for a code. `mcp_server` is the slug of the server you want —
# your admin console shows it next to each MCP server.
curl -s https://api.knoxcall.com/v1/oauth/device_code \
-d mcp_server=jira \
-d client_name="my laptop"
```
```json theme={"dark"}
{
"device_code": "…",
"user_code": "H4T9-KP2M",
"verification_uri": "https://app.knoxcall.com/ai-gateway/device",
"verification_uri_complete": "https://app.knoxcall.com/ai-gateway/device?user_code=H4T9-KP2M",
"expires_in": 900,
"interval": 5
}
```
Open `verification_uri_complete`, check that the screen names the MCP server and
the workspace you expected, and approve. Codes last **15 minutes** and can be
used **once**.
```bash theme={"dark"}
# 2. Poll for the token. Wait `interval` seconds between polls — polling faster
# is answered with `slow_down`, not with a token.
curl -s https://api.knoxcall.com/v1/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=…
```
While you have not approved yet, that returns `authorization_pending`. Once you
have, it returns the token — **once**. A second poll with the same `device_code`
is refused as a replay.
```json theme={"dark"}
{ "access_token": "kp_live_t_…", "token_type": "Bearer", "expires_in": 900 }
```
**Approve only a code you just generated yourself.** The approval screen shows
the name the client gave itself, and that name is not verified — anyone can
start a flow and ask you to approve it. Approving one hands *that* device a
token that acts as you.
## 2. Point your client at the server
All three clients read the same `mcpServers` map. Replace the token with the one
you just obtained, and the URL with the **Connect URL** shown on the MCP server's
page in your admin console.
`.mcp.json` in your project root (shared with your team), or `~/.claude.json` for
just you:
```json theme={"dark"}
{
"mcpServers": {
"jira": {
"type": "http",
"url": "https://api.knoxcall.com/v1/mcp/jira",
"headers": {
"Authorization": "Bearer kp_live_t_"
}
}
}
}
```
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
`%APPDATA%\Claude\claude_desktop_config.json` on Windows:
```json theme={"dark"}
{
"mcpServers": {
"jira": {
"type": "http",
"url": "https://api.knoxcall.com/v1/mcp/jira",
"headers": {
"Authorization": "Bearer kp_live_t_"
}
}
}
}
```
Restart Claude Desktop after editing the file.
`.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project:
```json theme={"dark"}
{
"mcpServers": {
"jira": {
"type": "http",
"url": "https://api.knoxcall.com/v1/mcp/jira",
"headers": {
"Authorization": "Bearer kp_live_t_"
}
}
}
}
```
## 3. What your token can and cannot do
| | |
| - | - |
| **One server** | The token is audience-bound (RFC 8707) to the MCP server you approved. Pointed at another, it is refused — not degraded, refused. |
| **One mode** | A token approved in Test mode reaches Test servers only, and resolves Test credentials. |
| **One person — you** | The subject is fixed at mint and is covered by the token's signature. `X-KC-User` is not an identity and is ignored. |
| **No AI agent** | It reaches no `/v1/ai` agent and no provider credential. It is a tool token, not a gateway key. |
| **15 minutes** | Re-run the device flow when it expires. |
## Troubleshooting
| What you see | What it means |
| - | - |
| `authorization_pending` | Nobody has approved the code yet. Keep polling on `interval`. |
| `slow_down` | You polled faster than `interval`. Wait and try again. |
| `expired_token` | The code is older than 15 minutes, or was never valid. Start again. |
| `access_denied` | Someone declined it on the approval screen. |
| `invalid_grant` | That `device_code` was already used. A token is issued once. |
| `401 insufficient_scope` on a tool call | The token is not a `tool` token — it did not come from this flow. |
| `401` naming a different resource | The token is bound to another MCP server. Get one for this server. |
| `401 passthrough_identity_unbound` | The token names nobody. Tokens from the device flow always do; this one did not come from it. |
## If your server uses delegated OAuth
A server in `auth.mode: "delegated"` also needs **your upstream account**
connected once, from the MCP server's page in the console — see
[the MCP gateway page](/ai-gateway/mcp-gateway). The MCP token above says who you
are to KnoxCall; the connection is what lets KnoxCall act as you at the upstream.
Nobody can connect on your behalf.
# Data Residency & Customer-Managed Keys
Source: https://docs.knoxcall.com/ai-gateway/data-residency
Pin an AI agent's upstream egress to a region, and encrypt its data at rest under a key your own KMS gates. Both fail closed.
# Data Residency & Customer-Managed Keys
Two per-agent controls for teams with a regulatory constraint on where their
prompts go and who can decrypt what the gateway keeps.
Both **fail closed**. If the gateway cannot prove a destination is in your
region, or cannot obtain your key, it refuses the request — it does not fall
back to a global endpoint or to a KnoxCall-held key.
Set both on the agent, not the gateway: a team usually has one gateway and
several agents, and residency is a property of the workload, not the container.
## Data residency
Set `data_residency_region` on an agent to one of `us`, `eu`, `uk`, `ca`, `au`,
`jp`, `in`.
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/$AGENT_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"data_residency_region": "eu"}'
```
From then on, every upstream call this agent makes on the `/v1/ai` data plane —
the primary route, **every fallback route**, the output-schema retry, the
semantic-cache embedder, and your PII analyzer sidecar if you have configured one
— must target an endpoint KnoxCall classifies as serving that region. Anything
else is refused with `403 residency_violation` before that call is made.
**The `/v1/mcp` plane is not covered yet.** An MCP server's `upstream_url` is
dialled without a residency check, so tool-call arguments and results from an
agent's MCP tools can leave the region even when the agent is pinned. If you are
pinning for a regulatory reason, do not attach MCP servers to that agent until
this lands. Tracked as AIGW-77.
The fallback path is the one that matters. An unpinned failover is exactly how a
Frankfurt workload ends up in `us-east-1` at 3am: the primary 5xxs, the gateway
does what it was configured to do, and nothing in the request looks wrong
afterwards. A pinned agent skips an out-of-region fallback and surfaces the
primary's error instead.
### What KnoxCall classifies
| Endpoint shape | Region |
| - | - |
| `bedrock-runtime..amazonaws.com` | from the AWS region (`eu-west-2` → **UK**, not EU) |
| `-aiplatform.googleapis.com` | from the Vertex region |
| `.api.cognitive.microsoft.com` | from the Azure region |
| `eu.api.openai.com` | EU |
| `api.mistral.ai` | EU |
| `api.cohere.com` | US |
### What it refuses, deliberately
| Endpoint | Why |
| - | - |
| `api.anthropic.com` | Anthropic's public API is a single global endpoint. Use Bedrock or Vertex in your region. |
| `api.openai.com` | OpenAI's default endpoint is global. Use `eu.api.openai.com` for EU. |
| `generativelanguage.googleapis.com` | Google's Generative Language API is global. Use a regional Vertex endpoint. |
| `.openai.azure.com` and `.cognitiveservices.azure.com` | These are two hostnames for the SAME Azure resource, and `` is a name its creator chose from a global namespace — nothing stops an East US resource being called `westeurope`. Neither can prove a region. Use the regional Cognitive Services endpoint. |
| anything else | A host KnoxCall cannot classify is refused, not allowed. |
**Unclassifiable means refused.** The three global endpoints above are exactly
the hosts most likely to be left in a route by accident, so "unknown = allow"
would make the pin decorative. If you need an endpoint we do not classify, open
a ticket — we would rather add a documented rule than have you turn the pin off.
### What this control is, precisely
It is an **administrative control on destination selection**. A pinned agent may
only egress to endpoints KnoxCall has classified as serving the pinned region.
It stops misconfiguration and silent drift, which is the failure mode that
actually produces residency incidents. It is **not** a cryptographic or network
guarantee: if you point your own route at a host you have classified in-region
and it is not, you are exporting your own data and no gateway can stop that.
Every vendor's "data residency" feature is this control. Ours says so.
### Auditing it
Each `ai_gateway_usage` row records the region the agent was pinned to **at the
time of that call**, so a residency audit reads evidence rather than the agent's
current configuration. It is returned by the usage export alongside cost and
attribution.
## Customer-managed encryption keys (CMEK)
Set `cmek_key_id` on an agent to the id of one of your tenant master keys. That
agent's PII token map — the reversible map behind [streaming PII
redaction](/ai-gateway/pii-redaction), which holds the original values — is then
encrypted under that key rather than the tenant's current active key.
### Prerequisite: BYOK
`cmek_key_id` must name a key whose `wrap_method` is `customer_kms` — a key your
own AWS/GCP/Azure KMS wraps. See [Bring your own key](/ai-gateway/byo-key).
**A key KnoxCall wraps is refused**, with `503 cmek_unavailable`. This is the
load-bearing check: without it, "CMEK enabled" could resolve to a
platform-wrapped key and you would have a green checkbox and no custody.
### What happens when the key is unavailable
Every one of these refuses the data-plane request with `503 cmek_unavailable`:
* the key does not exist, or is not this tenant's;
* the key is not customer-managed;
* your tenant has no active master key;
* your tenant's active key is no longer customer-managed (you rotated off BYOK);
* your KMS refuses to unwrap it (a revoked grant, a disabled key, an outage).
Nothing is written under a different key, and nothing is written in the clear.
Revoking the grant in your KMS therefore stops the agent within the key-cache
TTL — five minutes for BYOK keys — which is the same revocation contract the
rest of BYOK carries.
**Reads are not gated.** A KnoxCall envelope names its own key version, so rows
written before you attached a CMEK still decrypt. Refusing to read them would
destroy data you are entitled to see. Re-wrapping history under a new key is the
BYOK rewrap sweep's job, not this setting's.
### Key rotation
`cmek_key_id` names your customer-managed key **chain**, not one frozen version.
Rotating your tenant master key the normal way keeps the agent working: writes
follow whatever version of that chain is currently active. What is refused is a
chain that stops being customer-managed — if you rotate back onto KnoxCall's
platform wrap, the agent refuses rather than silently accepting a key we can
unwrap.
### Scope today
CMEK covers the agent's **PII token map** — the reversible `(surrogate →
original)` store, which is the one place the gateway durably holds your original
values.
Two honest limitations:
* **The response cache is not encrypted at all.** `ai_gateway_cache_entries`
stores response bodies as plain bytes, and the buffered path caches AFTER
detokenization — so for an agent with `pii_detokenize_response` on, the
original values would sit in that table in the clear for the cache TTL. Rather
than leave that beside a customer-managed key, **an agent with a `cmek_key_id`
does not use the response cache at all.** You lose the cache; you do not lose
custody.
* **The MCP plane's stored configuration** (`ai_gateway_mcp_servers`) is not
covered. Its upstream credentials are envelope-encrypted through the normal
tenant key — customer-managed too when BYOK is configured for the tenant — but
they do not follow the per-agent pin.
# Providers & Day-Zero Models
Source: https://docs.knoxcall.com/ai-gateway/day-zero-models
Fourteen supported upstreams — Anthropic, OpenAI, Gemini, Cohere, Azure OpenAI, Ollama, Groq, Together, Mistral, DeepSeek, Fireworks, xAI, AWS Bedrock and any OpenAI-compatible endpoint — plus the runbook for adding a new model the day it ships: a manifest update, no remote config fetch.
# Providers & Day-Zero Models
## Supported providers
Create an agent against any of these upstreams. Each agent gets its own route
with the provider key injected from a KnoxCall secret — your app never holds it.
| Provider | Upstream | Auth | OpenAI base path | Streaming redaction |
| - | - | - | - | - |
| **Anthropic** | `api.anthropic.com` | `x-api-key` | — (native shape) | ✅ mid-stream (holdback FSM) |
| **OpenAI** | `api.openai.com` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **Gemini** | `generativelanguage.googleapis.com` | `x-goog-api-key` | — (native shape) | ✅ mid-stream |
| **Cohere** | `api.cohere.com` | `Authorization: Bearer` | — (native shape) | buffered only |
| **Azure OpenAI** | your `*.openai.azure.com` resource | `api-key` | `/openai/v1` | ✅ mid-stream |
| **Ollama** (self-hosted) | your Ollama URL | optional Bearer | `/v1` | ✅ mid-stream |
| **Groq** | `api.groq.com` | `Authorization: Bearer` | `/openai/v1` | ✅ mid-stream |
| **Together AI** | `api.together.xyz` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **Mistral AI** | `api.mistral.ai` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **DeepSeek** | `api.deepseek.com` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **Fireworks AI** | `api.fireworks.ai/inference` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **xAI (Grok)** | `api.x.ai` | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
| **AWS Bedrock** | your `bedrock-runtime..amazonaws.com` | SigV4, signed by us | — (native shape) | buffered only |
| **Other OpenAI-compatible** | your base URL | `Authorization: Bearer` | `/v1` | ✅ mid-stream |
### The OpenAI base path matters
For every provider with a base path in that column, point an OpenAI SDK at
**the agent URL plus that path** — `OPENAI_BASE_URL=https://.knoxcall.com/v1/ai//openai/v1`
for Groq, `…/v1` for the rest. KnoxCall forwards `agent URL + everything after
it` to the provider's host, so the sub-path is what turns
`POST /chat/completions` into Groq's real `api.groq.com/openai/v1/chat/completions`.
The Connect tab renders the right one for you; the catalog is the source of
truth (`GET /admin/ai-gateway/quickstart/context` returns `openai_compat_path`
per provider).
### Tenant-supplied upstreams
Azure OpenAI, Ollama, Bedrock and "Other OpenAI-compatible" point at **your**
endpoint, so you supply the upstream base URL when creating the agent.
For Bedrock and "Other OpenAI-compatible" that URL is **resolved and checked
before the agent is created**: a hostname that resolves to a loopback, private,
link-local or cloud-metadata address is refused with a 400. That is not
paranoia about your network — the request carries your *decrypted* provider
credential to whatever that name resolves to, so it is the one place a typo or
a hostile DNS record costs you a key. Ollama is deliberately exempt: pointing at
your own box on `10.0.0.5` or `localhost:11434` is the entire point of it.
"Other OpenAI-compatible" also requires a `default_model`, because we cannot
know which model your endpoint serves — and for the same reason its cost is
recorded as `unpriced`. Azure and Ollama are unpriced too: an Azure deployment
is billed under your own Azure agreement, and a self-hosted Ollama box has no
per-token price at all.
### AWS Bedrock: you keep the key, we sign
Bedrock is the one provider with no static credential header. Its secret access
key is never transmitted — it is the HMAC key for a signature over the request
itself. So KnoxCall stores a **JSON AWS credential** as the secret:
```json theme={"dark"}
{ "accessKeyId": "AKIA…", "secretAccessKey": "…", "sessionToken": "optional" }
```
…and computes the SigV4 signature server-side over the finalized request, after
PII tokenization and body injection, immediately before the bytes leave. Your
application holds no AWS credential at all, and neither does anything downstream
of us.
Two limits worth knowing up front. Bedrock streams
`application/vnd.amazon.eventstream` — length-prefixed binary frames, not SSE —
so the mid-stream PII rewriter cannot apply; redaction runs on its **buffered**
responses, exactly as it does for Cohere. And Bedrock prices are region-scoped,
while [the shipped pricebook](/ai-gateway/budgets-finops) carries the
`us-east-1`/`us-west-2` rates, so an agent pinned elsewhere is estimated rather
than exact.
Gemini and Cohere use their own native request/response shapes — send Gemini
requests in Gemini's format and Cohere in Cohere's. The gateway proxies them
through unchanged; [PII redaction](/ai-gateway/pii-redaction),
[firewall](/ai-gateway/firewall), [budgets](/ai-gateway/budgets-finops), and
usage attribution all still apply. OpenAI ↔ Anthropic shape translation is
automatic on the buffered path for those two.
## Day-zero models — the runbook
When a provider ships a new model, you don't wait on a KnoxCall release. Two
steps, no remote config fetch (a deliberate post-supply-chain-compromise choice
— the gateway never pulls config from a third party at runtime):
1. **Use it immediately.** Set the new model id in your request (or as the
agent's default model). The gateway proxies any model the upstream accepts —
there is no allowlist unless you add one.
2. **Price it (for budgets/chargeback).** Add a row to the relevant
`packs/ai-gateway-pricebook/{provider}.json` manifest with the list price and
a dated `effective_from`, then reseed. Until then the call still succeeds and
is recorded with `cost_source = 'unpriced'` (never silently \$0), and the
weekly pricebook-drift check raises an alertable `ai_gateway.unpriced_model`
event so you know to add it. See [Budgets & FinOps](/ai-gateway/budgets-finops).
That's the whole loop: a new model works on day zero; adding one manifest row
makes its cost exact.
## Model allow/deny
Optionally constrain which models an agent may call via its model allowlist /
denylist (Models tab, or `model_allowlist` on the agent). A request for a model
outside the allowlist is rejected before it reaches the upstream.
# Errors
Source: https://docs.knoxcall.com/ai-gateway/errors
Every refusal the AI Gateway can return, the one envelope it arrives in, and which of them are safe to retry.
# Errors
The gateway refuses rather than degrades. Almost every entry on this page exists
because serving the request would have meant applying less governance than you
configured — so a refusal here is usually a control working, not a fault.
This page is the whole surface in one place: the envelope you parse, the codes
you can branch on, and what to do about each.
## One envelope
Every refusal on the AI data plane — `/v1/ai/*` and everything in front of it:
authentication, the MCP audience guard, the rate limiter, the boot sentinel —
arrives in exactly this shape:
```json theme={"dark"}
{
"error": "budget_exceeded",
"error_description": "Daily budget exceeded: $50.0031 ≥ $50",
"code": "budget_exceeded"
}
```
`error` and `code` always carry the **same** machine-readable identifier, so a
client that reads either keeps working. `error_description` is always the human
sentence — **never branch on it**; descriptions are rewritten whenever a clearer
wording is found, identifiers are part of the contract.
Some refusals add extra keys alongside the three (`utilization_pct`, `model`,
`request_id`). Ignore keys you do not recognise; new ones are additive.
**Shipped 2026-09-08 (AIGW-163).** Until then three incompatible shapes answered
this one URL and they disagreed about which field was machine-readable: the
pipeline sent `{error: "", error_description}`, the auth layer sent
`{error: "Unauthorized", code, reason}` — the inversion — and the MCP audience
guard sent a third reading in which `error` was a code that was not the `code`.
If you wrote a client against the previous advice on this page, you read `code`
and you are unaffected. **`reason` is gone**; its string is now
`error_description`. `error` on an auth refusal is now the code rather than the
word `Unauthorized`, and the `X-Auth-Failure` header still carries the same value.
The endpoint and this envelope are in the OpenAPI spec as
`POST /v1/ai/{agentSlug}/{upstreamPath}` — see
[Execute AI Request](/api-reference/ai-gateway/execute).
### Errors mid-stream
A streamed call commits its status line with the first frame, so a failure after
that cannot be a status code. It arrives as a terminal SSE event carrying the
same object:
```
event: error
data: {"error":"upstream_stream_failed","error_description":"The response stream failed after it had started. Some of the response may be missing; the request was not completed.","code":"upstream_stream_failed","request_id":"0d5b2a9e-…"}
```
Treat an SSE stream that ends **without** this frame and without the provider's
own terminal event as a transport truncation, and retry it.
### The management plane is a different envelope
Everything under `/v1/ai-gateway/*` — creating gateways, agents, tokens,
firewall policies, MCP servers, reading usage — is the **Management API**, not
the data plane. It has its own credential and its own standard envelope, which
is nested and is shared with every other `/v1` resource:
```json theme={"dark"}
{
"error": {
"type": "not_found",
"message": "Gateway not found.",
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
`error.type` is the machine field. See the
[control-plane overview](/api-reference/ai-gateway/control-plane-overview#errors).
This one is deliberately unchanged: unifying it would be a breaking change to
every resource in the product to fix an inconsistency that only ever existed
inside `/v1/ai`.
### The MCP plane speaks JSON-RPC
`/v1/mcp` answers JSON-RPC 2.0, so its refusals are that protocol's object, not
the envelope above:
```json theme={"dark"}
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "Method not found" }
}
```
| Code | Meaning |
| - | - |
| `-32600` | Invalid request. |
| `-32601` | Method not found — including `resources/*` and `prompts/*`, which the gateway deliberately does not proxy ([why](/ai-gateway/mcp-gateway#resources-and-prompts-are-refused)). |
| `-32001` | Unauthenticated, or the tool is not allowed. |
| `-32002` | Blocked by the prompt firewall. |
| `-32000` | A governance refusal — a spend cap, a token scope limit, a guardrail verdict. The HTTP status carries the class (`429` for a cap, and it carries `Retry-After`) and the message is the same sentence the `/v1/ai` refusal would have used. |
| `-32603` | Internal error — including a paused or archived agent or gateway. |
The one exception on that plane: a token presented to the wrong MCP resource is
refused by the audience guard, which is an HTTP layer and answers the envelope
at the top of this page.
## Retry, or don't
| Status | Meaning here | Retry? |
| - | - | - |
| **400** | Your request was refused by a policy you configured. | No — nothing changes on a retry. |
| **401** | The credential was rejected. | No — mint a new token. |
| **403** | The credential is valid but not permitted to do this. | No. |
| **404** | No agent with that slug for this tenant. | No. |
| **422** | The request was understood and is unprocessable (schema violation, unsupported option). | No. |
| **429** | A ceiling was reached — budget, token cap, or request rate. | Yes — **honour `Retry-After`**, which every 429 on this plane now carries. |
| **500** | A fault on our side. | Yes, with backoff. |
| **502** | The upstream provider failed. | Yes, with backoff. |
| **503** | A control could not be applied, so the request was refused rather than served ungoverned. | Yes, but it will keep failing until the underlying configuration or dependency is fixed — read the code. |
The 503s are the ones worth understanding: almost none of them mean "the gateway
is down". They mean the gateway could not prove it was enforcing what you asked
for, and refused instead of quietly doing less.
### `Retry-After` is exact, or absent
A `429` from a spend cap carries the whole number of seconds until that cap's
counter rolls over — daily budgets and signed token caps reset at **00:00 UTC**,
monthly budgets at the first instant of the next UTC month — because that is when
the refusal genuinely stops applying. A rate-limit `429` carries the remainder of
its window.
Where the server does not know the answer it sends **no header at all** rather
than a guess: a wrong value is worse than none, since a client backs off on it
either way. So `Retry-After` is never zero and never fractional, and its absence
on a 429 means "back off with your own schedule", not "retry now".
## Authentication
The code is also returned in the **`X-Auth-Failure`** response header, which lets
a client or a proxy classify the failure without reading the body.
| Code | Status | What happened |
| - | - | - |
| `missing_token` | 401 | No credential on the request. |
| `malformed`, `bad_prefix`, `bad_env`, `bad_kind`, `bad_random`, `bad_crc_format`, `crc_mismatch`, `empty` | 401 | The token did not parse. `bad_prefix` is the common one after the 2026-09-01 rename — a `kc_`-prefixed AI token no longer parses; re-mint. |
| `unknown_token` | 401 | Parsed, but matches no record. |
| `capability_hmac_mismatch` | 401 | The token's scope does not match its signature. |
| `tenant_key_missing` | 401 | The tenant master key was unavailable. |
| `dpop_missing`, `dpop_jkt_missing` | 401 | The token is DPoP-bound and the proof was absent or unbindable. Sign `htu` against the **canonical public URL**, not a rewritten `Host`. |
| `oneshot_already_used` | 401 | A one-shot token was replayed. It is consumed after authentication but *before* policy, so a call refused by a policy has still spent it. Mint one per attempt. |
| `oneshot_consume_error` | 401 | The consume could not be recorded, so the call was refused rather than risk a replayable one-shot. |
| `agent_missing` | 401 | The token names no agent — an agentless `tool` token presented to `/v1/ai`, which requires one. |
| `outside_valid_window` | 403 | Presented outside the token's `valid_window`. |
| `ip_cidr_mismatch` | 403 | Presented from outside the token's `ip_cidrs`. |
| `tenant_unavailable` | 403 | The workspace is not currently servable. |
| `step_up_required` | 401 | The token was flagged by [impossible-travel detection](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up). |
| `invalid_token`, `insufficient_scope`, `invalid_target` | 401 / 403 | The MCP audience guard: the token carries no authenticated scope, is not `tool`-kind, or is bound to a different MCP resource. Carries an RFC 6750 `WWW-Authenticate` challenge with the RFC 9728 `resource_metadata` pointer. |
| `unknown_mcp_resource` | 404 | The path is not an MCP resource on this host. |
| `missing_auth` | 401 | The request reached the rate limiter or the handler with no verified token — a mount error, refused rather than served unmetered. |
Every one of these now writes an `ai_gateway.request_denied` audit row, on both
planes — except a request with no token or an unknown token, which has no
workspace to attribute to and must not be able to write to your audit log.
## Policy and pipeline
### Credentials and routing
| Code | Status | What happened |
| - | - | - |
| `agent_not_found` | 404 | No agent with that slug for this tenant. |
| `agent_token_mismatch` | 403 | The token belongs to a different agent. |
| `agent_not_active` / `gateway_not_active` | 503 | The agent, or its gateway, is paused or archived. |
| `agent_not_configured` / `agent_route_missing` | 503 | The agent has no usable upstream route. |
| `route_env_mismatch` | 403 | A Test token addressed a Live route, or the reverse. |
| `route_field_actions_unsupported` | 503 | The route carries Relay field actions, which `/v1/ai` cannot apply. Refused rather than silently skipped. |
| `route_signature_unsupported` | 503 | The route requires a rolling URL signature, which `/v1/ai` cannot compute. |
| `agent_route_misconfigured` / `credential_unavailable` | 503 | The provider credential could not be resolved. |
| `linked_secret_domain_violation` | 403 | A shared secret's domain lock refused this upstream. Audited on **both** tenants. |
| `residency_violation` | 403 | The request would have left its [pinned region](/ai-gateway/data-residency). |
| `upstream_error` | 502 | The provider failed after every configured failover hop. |
| `upstream_stream_failed` | — | A STREAM failed after its first frame. There is no status left to change, so it arrives as the terminal SSE `event: error` above. |
| `ai_gateway_unavailable` | 503 | The plane failed to start. A platform fault, not your request. Carries `Retry-After`. |
### Model, tools and output
| Code | Status | What happened |
| - | - | - |
| `model_denylisted` / `model_not_allowed` | 403 | The requested model is denied, or not on the agent's allowlist. |
| `tool_not_allowed` | 403 | A tool outside the agent's `tool_allowlist`. |
| `batch_input_not_inspectable` | 422 | A batch envelope the gateway cannot open — and it will not forward what it cannot screen. |
| `output_schema_violation` | 422 | The response failed the agent's `output_schema` and the action is `block`. Under `warn` the response is served with `X-Knox-AI-Output-Warning`; a retried call carries `X-Knox-AI-Retry: output_schema`. |
### Firewall
See [Prompt firewall](/ai-gateway/firewall).
| Code | Status | What happened |
| - | - | - |
| `firewall_block` | 400 | A rule matched under a policy whose action is `block`. |
| `firewall_policy_unavailable` | 503 | The agent's policy could not be loaded. |
| `firewall_policy_missing` | 503 | The agent references a policy that no longer exists. Re-attach one, or clear `firewall_policy_id`. |
| `firewall_policy_degraded` | 503 | One or more rules failed to compile, so a `block` policy is only partly applied. On `warn`/`tag` the request proceeds and the degradation is written to your firewall event ledger. |
### Budgets and token caps
See [Budgets & FinOps](/ai-gateway/budgets-finops).
| Code | Status | What happened |
| - | - | - |
| `budget_exceeded` | 429 | The agent's (or its gateway's — the message is prefixed `Gateway budget:`) daily or monthly cap is spent. Carries `Retry-After`: the seconds to that cap's UTC rollover. |
| `budget_per_call_exceeded` | 400 | This one request asks for more than `budget_per_call_max_tokens`. |
| `budget_unenforceable` | 503 | The spend counter is unreadable, and the agent's action is `block` — so the request was refused rather than served unmetered. Responses in this state carry `X-Knox-AI-Budget-Unmetered: 1`. |
| `budget_unpriced_model` | 403 | The agent has a HARD spend cap and the model it would run on has no price in KnoxCall's pricebook — so the call would not move the spend counter and the cap could never bind. Refused PRE-flight, before the provider is paid. Add the model to the pricebook, choose a priced one, or set `budget_overage_action` to `warn`. Structural for `openai-compatible`, `azure-openai` and `ollama`, whose endpoint and model are yours. |
| `token_scope_limit_exceeded` | 429 | The **token's own** signed daily cost or token cap is spent. Carries `Retry-After` to the next 00:00 UTC. |
| `token_scope_malformed` | 403 | The token's scope carries an unusable limit. Re-mint. |
| `token_scope_unattributable` | 403 | The call cannot be attributed, so a token cap cannot be counted against it. |
| `token_scope_unenforceable` | 503 | The counter or pricebook needed to enforce a signed cap is unavailable. |
| `token_scope_check_failed` | 503 | The signed cap could not be read at all. Refused rather than served unmetered. |
| `token_scope_unpriced_model` | 403 | The model is not in the pricebook, so a signed cost cap could not be applied. |
| `provider_not_in_token_scope`, `model_not_in_token_scope`, `tool_not_in_token_scope`, `route_not_in_token_scope` | 403 | The token's capability scope does not cover this call. |
| `malformed_token_scope` | 403 | The scope is not a usable restriction — refused rather than read as "unrestricted". |
| `rate_limit_exceeded` | 429 | A request-rate ceiling — including too many **failed** authentications from one address, which is refused before the token is checked. Carries `Retry-After`. See [Limits](/ai-gateway/limits). |
### PII and guardrails
See [PII redaction](/ai-gateway/pii-redaction) and
[Guardrail webhook](/ai-gateway/guardrail-webhook).
| Code | Status | What happened |
| - | - | - |
| `pii_policy_unavailable` / `pii_policy_degraded` | 503 | The agent's PII policy could not be loaded, a recognizer failed to compile, or its Presidio analyzer could not run — including a timeout or error on this one request. Refused rather than served with redaction partly off; a per-request analyzer failure is retryable. |
| `pii_tokenize_failed` | 503 | Tokenization failed, so the prompt was not forwarded. The raw value never leaves. |
| `pii_detokenize_unavailable` | 503 | The token map needed to restore values was unavailable. |
| `guardrail_block` | 400 | Your guardrail webhook returned a blocking verdict. |
| `guardrail_streaming_unsupported` | 400 | A guardrail configuration that cannot be applied to a streamed response. |
| `streaming_redaction_unsupported` | 400 | A streamed response could not be redacted mid-stream, so it was refused rather than streamed in the clear: either the provider has no mid-stream redactor (Cohere, Bedrock), or the endpoint streams an event family KnoxCall does not read (the OpenAI Responses API, legacy `/v1/completions`). Retry buffered — drop `stream: true`. |
| `streaming_disabled` | 400 | The agent has streaming turned off, and the request asks the provider to stream (`stream: true`, or Gemini's `:streamGenerateContent`). Send it without `stream: true` (Gemini: `:generateContent`), or enable streaming on the agent. |
| `upstream_stream_shape_unexpected` | 502 | A streamed request was answered with a body that is not an event stream, which KnoxCall cannot redact frame by frame. Refused rather than forwarded unprotected. Retry without `stream: true`. |
| `pii_unscannable_body` | 400 | The request body is a shape the PII walker cannot open (a compressed or file-referenced batch), so it could not be screened. The gateway does not forward what it cannot screen. |
| `pii_tokenize_failed` | 503 | Tokenization threw. The prompt was not forwarded — the raw value never leaves. |
| `canary_leak` | 400 | The provider's response echoed this agent's system-prompt canary, which is proof the system prompt was extracted by the prompt in this request — not a heuristic guess. Refused when the attached firewall policy's action is `block`; under `warn` the response is served. **On a streamed response the refusal cannot be a `400`** — the `200` went out with the first frame — so the stream stops at the leak and ends with an `event: error` frame carrying this code; watch for that frame, not the status. The marker itself is spliced out of what you receive, buffered and streamed, **except in the `monitor` streaming mode**, which passes the response through unredacted by design. |
| `tool_arguments_too_large` | — (streamed only) | One streamed tool call's arguments exceeded 4,194,304 characters. The gateway holds each call's arguments until the call finishes so it can scan them for PII, and past that ceiling it stops the stream rather than release them unscanned. Arrives as an `event: error` frame; the status line already said `200`. See [streamed tool calls](/ai-gateway/pii-redaction#streamed-tool-calls). |
## What is recorded
Every refusal above writes one audit row under the single action
**`ai_gateway.request_denied`**, with the specific cause in the row's details.
One action name for every denial is deliberate: a new refusal reason cannot
invent an action nobody is alerting on. Alert on `ai_gateway.request_denied` and
read the reason from the row.
Every response — refusal or not — carries **`X-Request-Id`**. Quote it when you
contact support; it is the key into the audit trail for that exact call.
# Prompt Firewall
Source: https://docs.knoxcall.com/ai-gateway/firewall
Built-in prompt-injection heuristics, tenant-supplied regex/keyword rules, and canary tokens that detect system-prompt extraction — enforced on every request.
# Prompt Firewall
The prompt firewall screens every request before it reaches the provider. Built-in heuristics always run — they cannot be disabled — and you can layer your own rules on top.
## Built-in heuristics
The firewall ships with heuristics for common prompt-injection and jailbreak patterns (for example, instruction-override attempts and known jailbreak templates). When a request matches **and a policy with `action: "block"` is attached to the agent**, it is refused with `HTTP 400`:
```json theme={"dark"}
{
"error": "firewall_block",
"error_description": "Request blocked by firewall policy: ignore_previous_instructions, dan_jailbreak"
}
```
**Detection is on by default; blocking is not.** The built-in heuristics run on
every request whether or not a policy is attached, but with **no policy attached
the outcome is capped at `warn`** — the match is recorded, the request still
reaches the provider, and you get no `400`. The gateway never blocks on an action
you did not configure.
Nor does an agent created through `/v1` or the dashboard start out unprotected
*and* unblocking by accident: it is given the tenant's **Default** policy, whose
action is also `warn`. That is deliberate — a policy every future agent inherits
must not be able to start refusing their traffic on its own.
So blocking is opt-in on every path. Either change the Default policy's action to
`block`, or [create your own](#your-own-rules) with `action: "block"` and set the
agent's `firewall_policy_id`. Until you do, treat the firewall as an alerting
control, not a blocking one.
## Your own rules
A firewall **policy** attached to an agent can add:
* **Regex rules** — validated with a safe-regex guard so a pathological pattern can't cause catastrophic backtracking.
* **Keyword rules** — literal terms to block or flag.
A policy carries one `action`, which decides what a match does:
| `action` | Outcome on a match |
| - | - |
| `block` | The request is refused with `400 firewall_block` and never reaches the provider. |
| `warn` | The request proceeds; the match is recorded with `firewall_outcome = "warn"`. |
| `tag` | The request proceeds; the match is recorded with `firewall_outcome = "tag"`. |
With **no policy attached**, the outcome is `warn`.
Test a policy against sample input from the **Firewall tab** on the gateway detail
page, or programmatically with
[`POST /v1/ai-gateway/firewall-policies/test`](/api-reference/ai-gateway/firewall-policies-test),
before you attach it. The tester compiles rules through the same validator the
writer uses, so it refuses exactly what a create would refuse — a pattern that
passes the tester can actually run.
### When a blocking policy cannot be fully applied
A policy set to `block` is a promise, so the gateway refuses rather than serve a
request under a policy it knows is incomplete:
| Code | Status | When |
| - | - | - |
| `firewall_policy_unavailable` | 503 | The agent's policy could not be loaded (a database fault). |
| `firewall_policy_missing` | 503 | The agent references a policy id that no longer exists for this tenant. Re-attach a policy or clear `firewall_policy_id`. |
| `firewall_policy_degraded` | 503 | One or more rules failed to compile, so the policy is only partly applied. On a `warn` or `tag` policy the request proceeds instead, and the degradation is written to the tenant-visible firewall event ledger — it is never only a server-side log. |
## Canary tokens
Enable `canary_enabled` on a policy and the gateway injects a unique `kc_canary_<16hex>` token into every system prompt before forwarding upstream. If the model ever echoes that token back in its response — a strong signal that a prompt-injection attack extracted the system prompt — the gateway:
1. Emits an `ai_gateway.canary_leak` audit entry at **critical** severity.
2. Records the **policy's own action** as `firewall_outcome` on the usage record — `block` when the policy's action is `block`, otherwise `warn`.
3. Under a `block` policy, **refuses** the response: `400 canary_leak` when buffered, or — when streamed — stops the stream at the leak and ends it with an `event: error` frame carrying `canary_leak`. Anything already sent before the leak contained no canary and stays delivered.
4. Fires any [compliance-pack](/ai-gateway/compliance-packs) alert rules listening for `ai_gateway.canary_leak`.
The canary is stripped from the response before it reaches your app, so it's invisible in normal operation — **except in the `monitor` streaming mode**, which passes the response through unredacted and only inspects it at the end. An agent in `monitor` mode delivers a leaked canary before any refusal can fire, so do not pair it with a `block` policy you are relying on.
### Where the canary is placed
The canary goes into the system prompt of the request you send, in the form that
request's API uses. Your own system prompt is always forwarded unchanged; the
canary is added alongside it.
| Request body | Where the canary goes |
| - | - |
| Anthropic Messages, and Bedrock InvokeModel with an Anthropic body | Appended to `system`: a new line on a string, or one more `text` block on a block array |
| OpenAI Chat Completions, OpenAI-compatible providers, Cohere v2 | A leading `system` message |
| OpenAI Responses | A leading `developer` item in `input` — not `instructions`, which the API echoes back in its response |
| Gemini | One more part on `systemInstruction` (or `system_instruction`) |
| Bedrock Converse | One more `{ "text": … }` block on `system` |
| Cohere v1 | Appended to `preamble` |
| Legacy completions, embeddings, batch envelopes | Nothing — these have no system prompt, so no canary is sent and none is watched for on that request |
## Outcomes in telemetry
The firewall outcome is recorded on every call and exported on the [OpenTelemetry span](/ai-gateway/observability) as `knoxcall.ai_gateway.firewall_outcome` (`pass` / `warn` / `block` / `tag`), so you can alert and dashboard on it in your own backend.
# Bring Your Own Scanner
Source: https://docs.knoxcall.com/ai-gateway/guardrail-webhook
Point an agent at your own DLP or prompt-security endpoint and every prompt — and optionally every response — is offered to it for a verdict before it moves. Signed, timed out, and fail-open or fail-closed by your choice.
# Bring Your Own Scanner
KnoxCall ships its own [prompt firewall](/ai-gateway/firewall) and its own
[PII detectors](/ai-gateway/pii-redaction). They are also *ours*. If you have
already bought and tuned a DLP or prompt-security product, adopting an AI gateway
should not mean replacing it.
So don't. Point the agent at an HTTPS endpoint you control, and every prompt —
and, if you want, every response — is POSTed to it for a verdict before it moves.
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/$AGENT_ID \
-H "Authorization: Bearer $KNOXCALL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"guardrail_webhook_url": "https://scanner.internal.example.com/inspect",
"guardrail_webhook_secret_id": "'"$SIGNING_SECRET_ID"'",
"guardrail_webhook_mode": "both",
"guardrail_webhook_timeout_ms": 1500,
"guardrail_webhook_failure_action": "fail_closed"
}'
```
## What your endpoint receives
```json theme={"dark"}
{
"direction": "request",
"tenant_id": "…",
"agent_id": "…",
"agent_slug": "support-bot",
"request_id": "…",
"model": "claude-sonnet-5",
"provider": "anthropic",
"body": "{\"messages\":[{\"role\":\"user\",\"content\":\"my SSN is KC_SSN_a1b2\"}]}",
"truncated": false
}
```
`body` is **exactly what the upstream provider would receive, minus the
credential** — the same buffer, taken at the same point in the pipeline. That is
the guarantee, and it is the one worth having: your scanner is a third party from
KnoxCall's point of view, and it is never shown more of your callers' data than
the model provider is.
Read that as written: **the same bytes as the provider**, not "guaranteed
redacted". If the agent has PII tokenization enabled, the hook sees the tokenized
form — the example above shows `KC_SSN_a1b2`. If it does not, or if the request
uses a body shape our outbound tokenizer does not yet walk (a non-JSON body;
Cohere's `message`/`chat_history`; tool descriptions), the hook sees the raw
value — because so does the provider. Tokenization coverage is tracked as
AIGW-212; until it closes, treat your scanner as being inside the same trust
boundary as your model provider, and pick it accordingly.
Three things your endpoint will **never** see:
* a resolved provider credential — on the request direction because evaluation
runs before the route's `{{secret_id:…}}` templates are rendered and before
Bedrock's SigV4 signing, and on the response direction because every
credential resolved for the request is redacted out of the body by VALUE
before the payload is built. Redacting on the value is what makes the second
case hold: the response is the provider's, the provider is whatever host your
route points at, and it can echo the key back under any field name it likes —
a name a denylist cannot predict. Where a value is removed you will see
`[REDACTED_SECRET]` in `body`;
* the caller's phantom token, or their KnoxCall API key;
* KnoxCall's own canary marker, which is injected after the hook runs (a scanner
that had never heard of it would reasonably flag it as an anomaly).
On the **response** direction, `body` is the response as your caller would
receive it — after PII redaction and detokenization, and after the credential
scrub above. If the provider compressed it (`Content-Encoding: gzip`, `br`,
`deflate`), it is **decompressed for you**: a scanner handed raw gzip bytes
cannot scan them. A response we cannot decompress is sent as an empty `body`
rather than as bytes neither of us can read — your hook still runs and can still
block.
A body over 256 KB is **truncated, not skipped**, and `truncated: true` says so.
A scanner that knows it saw a prefix can still refuse; one that is silently never
called cannot.
## What your endpoint returns
`200` with:
```json theme={"dark"}
{ "action": "block", "reason": "PCI data detected in prompt" }
```
| `action` | What happens |
| - | - |
| `allow` | The request proceeds. |
| `block` | 400 `guardrail_block`, with your `reason` in the message. On the request direction the provider is never called at all. |
| `flag` | The request proceeds and the outcome is recorded as non-passing on the usage row and in the firewall event ledger. |
Anything else — a different verb, a non-object body, an empty body, a non-2xx
status — is treated as a **non-answer**, not as `allow`. See below.
## Verifying the signature
Set `guardrail_webhook_secret_id` to a KnoxCall secret holding an HMAC key, and
every delivery carries:
```
x-knox-guardrail-timestamp: 1787659200
x-knox-guardrail-signature:
x-knox-guardrail-delivery:
```
The signature is `HMAC-SHA256(secret, ".")`. The timestamp is
**inside** the signed material, not merely alongside it — a signature over the
body alone is replayable forever.
```python theme={"dark"}
import hmac, hashlib, time
def verify(secret: str, timestamp: str, body: str, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # reject stale deliveries
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature) # never ==
```
Use a constant-time comparison. A `==` leaks the expected signature a byte at a
time, and it is our HMAC it leaks.
## When your scanner is down
This is the decision that matters, and it is yours rather than ours — a
marketing-copy agent should keep serving; a claims-processing agent should not.
| `guardrail_webhook_failure_action` | Behaviour |
| - | - |
| `fail_open` (default) | The request proceeds. This is the posture an agent already has with no hook configured. |
| `fail_closed` | The request is refused with 400 `guardrail_block` and a message naming the failure. |
It applies **identically to every way the hook can fail to answer**: a timeout, a
refused connection, a refused destination, a non-2xx, an unparseable body, or an
`action` verb we do not recognise. A control with six failure modes and two
behaviours is one nobody can reason about, and the gap is always the mode nobody
enumerated.
## Streaming
An agent in `response` or `both` mode **refuses a streaming request** with 400
`guardrail_streaming_unsupported`.
That is deliberate. On a stream the bytes are on the wire before a complete
response exists, so a response-direction hook could observe but never block — and
a control that works on the buffered path and quietly does not on its streaming
twin is worse than no control, because you would believe you had one. If you need
both, run the hook in `request` mode (which works on every path, since it runs
before dispatch) or serve that agent buffered.
## The limits, and why
| | |
| - | - |
| Scheme | `https` only, not negotiable |
| Destination | Resolved and refused at write time **and** on every call — private, loopback, link-local and cloud-metadata addresses are rejected |
| Timeout | 100 ms – 10 s |
| Payload | 256 KB, then truncated with a flag |
| Response read | 64 KB |
The destination is re-checked on every call rather than trusted from the write:
DNS is not a promise, and a hostname that resolved to a public address yesterday
can resolve to `169.254.169.254` today. The timeout and read caps exist because a
request waiting on your scanner is holding one of the gateway's API workers, and
there are only four of them.
## What gets recorded
A non-`allow` verdict lands in two places you can query:
* `ai_gateway_usage.firewall_outcome` — `block` or `tag`, alongside the cost and
token counts for that call;
* an `ai_gateway_firewall_events` row whose `match_summary` reads
`guardrail_webhook::` — so
`guardrail_webhook:request:timeout` is distinguishable from
`guardrail_webhook:request:block` when you are working out whether your scanner
is refusing things or simply unreachable.
# Limits & Availability
Source: https://docs.knoxcall.com/ai-gateway/limits
The concrete ceilings the AI Gateway data plane enforces — request rates, body sizes, token TTLs, scan and hold-back bounds — and an honest statement of what we do and do not promise about availability.
# Limits & Availability
Every number on this page is the value the gateway actually enforces. Where a
limit is not enforced, this page says so rather than leaving you to infer one.
## Request rate
Sliding windows over a shared cross-worker store, so the ceiling is the ceiling
across the whole fleet — not per process.
| Plane | Bucket | Ceiling |
| - | - | - |
| `/v1/ai/` | per phantom token | **300 / minute** |
| `/v1/ai/` | per agent (all of that agent's tokens together) | **1,200 / minute** |
| `/v1/mcp/` | per phantom token | **600 / minute** |
| `/v1/oauth/token` | per (client IP, issuer, subject) | **30 / minute** |
| `/v1/oauth/token` | per client IP | **60 / minute** |
| `/v1/ai` and `/v1/mcp` together | **failed** authentications per client address (an IPv6 /64 counts as one) | **60 / 5 minutes** |
The failed-authentication bucket counts only requests whose phantom token was
refused — missing, unknown, expired, revoked, or presented from outside its
allowed window or network. Successful requests never count, however many share
an address. Once an address has used its allowance, every request from it to
either plane is refused with `429` **before** its token is checked, until the
window drains; `Retry-After` says when.
These are abuse floors, not product quotas: they are the same on every plan, and a
normal integration should never meet one. Exceeding a bucket returns `429`.
The rate limiter **fails closed**. If its store is configured but unreachable, the
plane refuses rather than admitting unmetered traffic — for a gateway whose job is
credential custody, an unmetered data plane is the worse failure. (In a local
development install with the store deliberately disabled it falls back to an
in-process window, so limits still apply while you develop.)
## Concurrent MCP sessions
| Plane | Bucket | Ceiling |
| - | - | - |
| `/v1/mcp/` | per token, per server, per environment | **16 open upstream sessions** |
An MCP client picks its own `mcp-session-id`, so without a cap it would pick how
many credentialed upstream connections KnoxCall holds open on its behalf. Past the
ceiling the least-recently-used session is **closed**, not refused: a resource
bound should not become a `503` for a client that opened one connection too many.
Sessions are also swept after 5 minutes idle and retired after 15 minutes
regardless, so a credential the upstream has revoked stops working on a schedule
we control.
16 is the number across the whole fleet. It is declared that way in the code
(`MAX_SESSIONS_PER_PRINCIPAL_FLEET`) and divided by the API worker count, because
the session pool is necessarily per-process — it holds real sockets — and a
per-worker figure would quietly multiply into a limit nobody had written down.
## Request size
| Plane | Maximum request body |
| - | - |
| `/v1/ai/` | **1 MB** |
| `/v1/mcp/` | **4 MB** |
| `/v1/oauth/token` | **32 KB** |
An oversized body is rejected by the body parser before any gateway policy runs.
There is **no ceiling on response size** — the gateway streams or buffers whatever
the upstream returns.
## Tokens
| Limit | Value |
| - | - |
| Phantom-token TTL, minimum | **60 seconds** |
| Phantom-token TTL, maximum | **90 days** |
| Phantom-token TTL, default | **30 days** — a mint without `expires_in_seconds` gets this bounded TTL, not a permanent token |
| Federated token TTL (`/v1/oauth/token`) | the binding's `access_token_ttl_seconds`, default **900 seconds** |
| DPoP proof `iat` tolerance | **±60 seconds** of server time |
A requested TTL outside `[60s, 90d]` is clamped into range rather than rejected.
The default is a bounded 30-day TTL, not "no expiry" — a mint without
`expires_in_seconds` used to produce a token that never expired at all, and any
key minted before that changed is still out there with a NULL `expires_at`.
Set `expires_in_seconds` explicitly on every mint that is not a hand-held
credential, and re-mint anything issued earlier that you cannot account for.
## Guardrail bounds
| Limit | Value | What it means |
| - | - | - |
| Prompt-firewall scan window | **64,000 characters** | The firewall scans the first 64 KB of extracted prompt text. Text past it is not scanned. |
| Streaming hold-back window | **96 characters** minimum, 96 default | The sliding tail the streaming redactor holds back. Values below the floor are raised to it, because a smaller window silently loses detection of anything longer than it. |
| Streaming hold-back ceiling | **8 KB**, or 8× the configured window | A pattern that never completes is redacted rather than held for ever. |
| Content blocks tracked per stream | **64** | One redaction window per block (`n>1` completions, multi-block messages). Past 64 the extras share a window: still redacted, only their interleaving degrades. |
| MCP sessions per token, per server, per environment | **4** | The least recently used is closed when a fifth is opened. |
| MCP session idle timeout | **5 minutes** | Swept every 60 seconds. |
## Caching
| Limit | Value |
| - | - |
| Cache TTL | `cache_ttl_seconds` on the agent, default **0** (no expiry-based eviction) |
| Semantic similarity threshold | `cache_similarity_threshold`, default **0.95** |
| What is cacheable | buffered `2xx` responses only — streaming responses are never cached |
## What is *not* limited
Stated explicitly, because an unstated limit reads as an enforced one:
* **No per-tenant or per-plan request quota.** The ceilings above are platform-wide
abuse floors. Plan-based quotas are a separate piece of work.
* **No token-count quota.** Spend is bounded in dollars by
[budgets](/ai-gateway/budgets-finops), not by a token allowance.
* **No response-size ceiling.**
* **No cap on agents, gateways, or tokens per tenant.**
## Availability
KnoxCall does **not** publish an uptime percentage for the AI Gateway, and will not
until one can be measured against a defined error budget and backed by a credit
schedule. Quoting a number we cannot compute would be worth less than saying so.
What we do commit to today:
* **Live status and incident history** at
[status.knoxcall.com](https://status.knoxcall.com), which is where an incident is
posted first.
* **Degrade toward refusal, not toward silence.** When a control cannot run, the
gateway refuses the request rather than serving it with the control off — an
unloadable firewall policy, an unresolvable PII policy, an unreachable rate-limit
store and an unproven token environment all fail closed. Detection-only signals
(impossible-travel geolocation) fail open, because losing one comparison costs a
comparison while blocking on it costs access.
* **No silent zeros.** An unpriced model is recorded as `unpriced`, never as \$0; a
missing usage figure is reported as missing.
* **Failover you configure.** Per-agent [failover chains](/ai-gateway/budgets-finops#failover-chains-a-different-kind-of-fallback)
move a 5xx to the next upstream you nominate, each hop with its own credential.
If you need a contractual availability commitment, raise it with us before you
build against one — we would rather negotiate a number we can hold than publish one
we cannot.
# MCP Gateway
Source: https://docs.knoxcall.com/ai-gateway/mcp-gateway
Govern MCP tool traffic like any other AI egress: DPoP-bound capability tokens as the agent credential, a tool allowlist, PII inspection of tool arguments and results, and prompt-injection screening — in front of your MCP servers.
# MCP Gateway
The Model Context Protocol (MCP) lets agents call tools on MCP servers. KnoxCall puts those calls behind the same governance as the rest of your AI egress — so an agent presents a **short-lived, sender-constrained KnoxCall token** instead of a long-lived server credential, and every `tools/call` is screened before it reaches the upstream.
Point an MCP client at `https://.knoxcall.com/v1/mcp/`.
## What it governs
For a request through an MCP server, the gateway:
* **`initialize` / `ping`** — pass through to the upstream. They carry protocol negotiation, not tenant data.
* **`tools/list`** — returns only the tools your **allowlist** permits (intersected with the token's tool scope). Tools you haven't allowed are never advertised.
* **`tools/call`** —
1. **Authorizes** the tool against the allowlist — a call to a tool that isn't allowed is rejected with a JSON-RPC error and **never forwarded**.
2. **Screens the arguments** through the [prompt firewall](/ai-gateway/firewall) — an injection attempt in a text argument is blocked.
3. **Redacts PII in the arguments** before forwarding, so sensitive data never reaches the upstream MCP server.
4. Forwards the (redacted) call to the upstream.
5. **Redacts PII in the result** on the way back to the client.
Every governed call is recorded in `ai_gateway_mcp_calls` with its outcome
(`ok` / `blocked_tool` / `blocked_firewall` / `error`), its tool name, its
PII-redaction count and its latency.
A **refusal** additionally writes an audit row under the single action
`ai_gateway.request_denied`, with the specific cause in the row's details —
`mcp_tool_blocked`, `mcp_firewall_block`, `mcp_upstream_refused`,
`firewall_policy_unavailable`, `firewall_policy_missing`. One action name for
every denial is deliberate: a new refusal reason cannot silently invent an action
nobody is alerting on. Alert on `ai_gateway.request_denied` and read the reason
from the row.
**Your system-prompt canary is watched for in both directions.** The canary the
[prompt firewall](/ai-gateway/firewall) injects into your agents' system prompts
has no reason to appear in tool traffic — nothing on this plane injects one — so
if it does, an agent has extracted a system prompt and is moving it through a
tool. The most direct route is the call's **arguments**, out to an upstream an
attacker controls. Wherever it appears, the gateway strips it (`[REDACTED:CANARY]`)
before it leaves and raises an `ai_gateway.canary_leak` audit entry at
**critical** severity against the MCP server — one per call, however many times
the marker appeared, and carrying a count rather than the marker itself.
That alert is **not** an `ai_gateway.request_denied` row, because the call is not
refused: the marker never reaches the upstream, and no policy on this plane asks
for more. Alert on `ai_gateway.canary_leak` separately. If none of your agents has
ever run with a canary, there is nothing to watch for and nothing is scanned.
### `resources/*` and `prompts/*` are refused
The gateway governs **tools**. `resources/read`, `resources/list`, `prompts/get`
and `prompts/list` are answered with JSON-RPC `-32601` (method not found) and are
never proxied to your upstream.
This is a decision, not an oversight: `resources/read` returns file and data
content and `prompts/get` returns prompt text — exactly the payloads the tool-call
controls exist to screen — so proxying them would be a way to pull that content
through the gateway with none of those controls applied. An MCP server whose value
is in its resources is not usable through KnoxCall today; say so to us if that is
your case.
## The credential
MCP tool access uses a **tool-bound phantom token** (kind `tool`), [DPoP](/ai-gateway/tokens-and-dpop)-bound by default — sender-constrained, short-lived, and revocable. This is the credential the MCP spec recommends and that almost nobody productizes: your agent never holds the upstream server's key, and a leaked token is useless without its DPoP key.
## How the server authenticates upstream
Three modes, set per server on `auth.mode`.
### `secret` (default)
The server dials its upstream with one credential resolved from your secret
store: `{ "headers": { "Authorization": "Bearer {{secret_id:}}" } }`. The
credential is envelope-encrypted at rest, decrypted per connection, and never
stored in the server row — a literal value there is refused at write time and
again at connect time.
Every user of the server reaches the upstream as **the same principal**. That is
fine for a read-only internal service, and it is the wrong shape for anything
where the upstream has its own idea of who may do what: a shared Jira credential
sees every project any of your users can, regardless of which of them asked.
### `passthrough` — each user acts as themselves
```json theme={"dark"}
{ "mode": "passthrough" }
```
The caller supplies their own upstream credential per request:
```http theme={"dark"}
POST /v1/mcp/my-jira
Authorization: Bearer kp_live_t_... # your KnoxCall tool token, minted FOR YOU
X-KC-Upstream-Authorization: Bearer
```
KnoxCall forwards that value to the upstream as `Authorization` and **stores
nothing**. The upstream applies its own authorization to the real person, so
least privilege is whatever your upstream already enforces — not whatever a
shared service account happens to be able to do.
What the gateway still does on every call: the tool allowlist, the token's own
`tools` scope, the prompt-injection firewall on tool arguments, and PII
redaction of arguments and results. What it stops doing is holding a credential.
**Rules this mode enforces, and why:**
* **The token has to name a person — and only the token does.** The credential
belongs to a human and the upstream will act as that human; the only record
connecting the two is the one we write. So the identity comes from the
**subject bound into your `tool` token**, which is covered by the token's
capability signature and fixed at mint. A token that names nobody is refused
on a passthrough server, whatever headers it sends.
`X-KC-User` is **not** an identity here and is ignored for binding. It used to
be one, checked only against your directory — which meant anyone holding a
credential could record the action against any provisioned employee. If you
send it and it disagrees with the token, the call is still attributed to the
token's subject and the disagreement is logged.
Two ways to get a subject-bound token, both of which prove the person before
minting:
| Path | Who it is for | How the person is proven |
| - | - | - |
| OIDC exchange with a binding that sets `subject_claim` | a workload acting for a named user | the claim is read out of an id\_token your IdP **signed** |
| Device-code grant | a human on Claude Code / Claude Desktop / Cursor | they sign in to KnoxCall and approve the device |
Set `subject_claim` on the binding to the claim that names the person (for
example `email`). Its value is resolved against your directory
(`ai_gateway_user_attribution`) at exchange time; a claim naming someone you
have not provisioned is refused rather than minted unbound. Leave it null for
ordinary CI — a build job is not a person, and a token with no subject keeps
working everywhere except a passthrough server.
* **No stored headers.** A passthrough server cannot also carry `auth.headers` —
sending both would put the shared credential and the user's on the same
request and let the upstream pick.
* **Missing credential = refused, never anonymous.** There is nothing to fall
back to, and dialling unauthenticated would look like the feature working.
* **A KnoxCall token is refused.** If the header value looks like a KnoxCall
credential — `tk_` (tenant API key), `kc_` (OAuth 2.1 access token), `kr_`
(OAuth 2.1 refresh token), `kp_` (AI data-plane phantom token) or `AKE`
(enterprise access key) — the call is rejected: that header goes to a
third-party server, which has no use for it. The list is
`KNOXCALL_CREDENTIAL_PREFIXES` in `src/proxy/passthrough.ts`, and every
authenticator's prefix has to be on it.
* **Connections are never shared between users.** Upstream connections are
pooled for reuse; the pool key includes the token's bound subject and a
non-reversible tag derived from the credential, so two people can never end up
on one connection — including when they hold the same token and send the same
`Mcp-Session-Id`.
The credential is not written to logs, audit rows, traces, or usage records, and
it is never echoed in an error message.
### `delegated` — each user acts as themselves, without carrying a credential
```json theme={"dark"}
{
"mode": "delegated",
"delegated": {
"authorize_url": "https://provider.example.com/oauth/authorize",
"token_url": "https://provider.example.com/oauth/token",
"client_id": "your-oauth-app-client-id",
"client_secret_ref": "{{secret_id:}}",
"scopes": ["read:issues", "write:issues"]
}
}
```
Passthrough asks every caller to carry their own upstream credential. Delegation
is for when they should not have to: the person **consents once** through the
upstream's own OAuth, KnoxCall envelope-encrypts their refresh token under your
tenant key, and mints a short-lived access token per call. The upstream still
applies its own authorization to the real person — so least privilege is still
whatever your upstream already enforces — and nothing in the MCP client's
configuration holds a secret.
**How a person connects.** From the MCP server's page in the KnoxCall console:
**Connect my account** → the provider's consent screen → back to KnoxCall. That
is the only way, and the reason is the same one that decides identity above:
**Nobody can connect on your behalf, and there is no API for it.** Consent has
to be given by the person whose credential it is. If an administrator could
start a flow naming a colleague and then finish it with their *own* upstream
account, the stored connection would hold the administrator's credential under
the colleague's name — which is exactly the defect that made `X-KC-User` stop
being an identity. `/v1` can **list** and **revoke** connections; it cannot
create one.
**Set up your OAuth app** with redirect URI `https://oauth.knoxcall.com/callback`
— the one KnoxCall publishes, the same for every tenant and every server, because
providers match redirect URIs exactly. Ask for the narrowest scopes that do the
job: an empty scope list is refused, because most providers read it as "give this
app your default grant", which is the widest one they offer.
**Rules this mode enforces, and why:**
* **The token has to name a person**, exactly as in passthrough. A call whose
token names nobody is refused; a person with no connection is refused with a
message telling them to connect, not with an upstream error.
* **`client_secret_ref` is a secret reference, never a literal.** `auth` is not
encrypted and is returned by every read path, so a literal client secret would
sit in cleartext in the control plane. A literal is refused with 422.
* **Both endpoints must be public `https://`.** Refused at write time, and the
connection is dialled through the gateway's resolve-and-pin egress, which
validates every DNS answer and refuses redirects — the request carries a live
credential.
* **PKCE (S256) is always sent**, whatever the client type. The authorization
code comes back through a redirect the user's browser follows, and an
intercepted code with no verifier is that person's upstream account.
* **Refresh is single-flight across the fleet.** Four API workers refreshing one
connection at once is how a provider that rotates refresh tokens invalidates
three of them and locks the user out of their own account.
* **Only the person who started a connection can finish it.** The callback
requires a KnoxCall session and refuses if it is not the same user. Without
that, someone could start a connection as themselves, send you the link, and
have your upstream credential stored under their name — the refusal is
audited, and nothing is stored.
* **Changing `auth` revokes every connection on the server**, and so does
archiving it. The stored tokens were issued for one app, one set of scopes and
one upstream; keeping them across a change would mean dialling with tokens
minted under settings nobody can see any more.
* **Revoking destroys the stored tokens**, it does not set a flag. It does *not*
revoke KnoxCall's authorization at the provider — do that in the provider's own
account settings if you need it gone there too.
* **Connections are never shared between users**, on the same key as passthrough:
the pool key carries the token's bound subject and a non-reversible tag of the
minted access token, so a refresh opens a new connection rather than leaving an
expired token in service.
Nobody at KnoxCall, and no administrator in your tenant, can read a stored
refresh token: it is decrypted only in the worker that is about to use it, and no
API response carries it in any form.
## Getting a token as a person
A `tool` token can be minted two ways, and only these two — both prove who you
are before minting, because the token speaks for a person once it exists:
* **the OIDC exchange**, for a workload (a CI job presenting an id\_token from a
bound issuer). Set `subject_claim` on the binding when that workload acts for
a named user;
* **the device flow**, for a human at a laptop. See
[Connect Claude Code, Claude Desktop and Cursor](/ai-gateway/connect-mcp-clients).
## Discovery & audience-bound tokens (OAuth 2.1)
A standards-compliant MCP client (Claude Code, Claude Desktop, Cursor) auths without any KnoxCall-specific wiring:
1. **Protected-resource metadata** — [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728). On a `401`, the client fetches:
```
GET https://.knoxcall.com/.well-known/oauth-protected-resource/v1/mcp/
```
which returns the authorization server to use and the DPoP algorithms this
resource accepts:
```json theme={"dark"}
{
"resource": "https://.knoxcall.com/v1/mcp/",
"authorization_servers": ["https://.knoxcall.com"],
"bearer_methods_supported": ["header"],
"dpop_bound_access_tokens_required": false,
"dpop_signing_alg_values_supported": ["ES256"]
}
```
`dpop_bound_access_tokens_required` is `false` because DPoP binding is set
**per token**, not per resource: a token minted with `dpop_required` must
always be presented with a proof, and one minted without it may not use the
DPoP scheme at all. Sender-constraining a token is strongly recommended and
is what the flow below does — but a resource that returned `true` here would
be promising that *every* token it accepts is bound, which is not something
this server enforces.
2. **Authorization-server metadata** — [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414). The client reads `https://.knoxcall.com/.well-known/oauth-authorization-server` to find the token endpoint.
3. **Resource-indicated token** — [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707). When exchanging a workload identity for a token at `/v1/oauth/token`, the client passes a **`resource`** naming the exact MCP server:
```
resource=https://.knoxcall.com/v1/mcp/
```
The minted token is **audience-confined** to that resource — bound into its capability HMAC, so a token issued for `/v1/mcp/weather` cannot be replayed against `/v1/mcp/payments`. The `resource` must name an MCP server on your own domain; a foreign or malformed resource is rejected. Multiple resource indicators are not supported — pass exactly one.
## Registering a server
Create one under the gateway's **MCP** tab, or through the API:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY_ID/mcp-servers -H "Authorization: Bearer $KNOXCALL_KEY" -H "Content-Type: application/json" -d '{
"name": "Vendor tools",
"slug": "vendor-tools",
"upstream_url": "https://mcp.vendor.example/mcp",
"allowed_tools": ["get_weather"],
"auth": { "headers": { "Authorization": "Bearer {{secret_id:}}" } }
}'
```
The response carries two URLs, and they are **not** the same string:
* `connect_url` — where an MCP client points.
* `resource` — the RFC 8707 value a token for this server must be bound to (see above).
Three rules the API enforces, so they are worth knowing before you hit them:
* **`upstream_url` must be a public `https://` address.** Private, loopback, link-local and
cloud-metadata destinations are refused when you save the server and again when we connect —
the request carries your decrypted upstream credential, so it is never sent in cleartext or to
an address inside anyone's network.
* **Upstream auth must reference a KnoxCall secret**, e.g.
`"Bearer {{secret_id:}}"`. A literal credential is rejected: it would sit unencrypted in
the control plane, which is the opposite of what you use KnoxCall for.
* **An empty `allowed_tools` advertises nothing.** That is the default, and it is deliberate —
a newly registered server exposes no tools until you name them.
## Sessions
The gateway keeps an initialized upstream connection alive per session so a client
doesn't re-handshake on every call. Two properties are worth knowing:
* **A session belongs to one token.** The pool is keyed on the verified capability
token, so two tokens never share an upstream connection — nor the decrypted
upstream credential behind it — whatever `mcp-session-id` they send. Before
2026-08-25 the client-supplied header was the only separator; see the
[changelog](/ai-gateway/changelog).
* **A token holds at most 4 concurrent sessions** per server per environment.
Opening a fifth closes that token's least recently used one. Idle sessions are
closed after 5 minutes.
The upstream credential is resolved per connection in the **environment the
presenting token names** (`kp_live_` / `kp_test_`), and a request whose environment
cannot be proven is refused rather than assumed to be Live.
## Two kinds of MCP server
* **Upstream** — proxies an external MCP server (its transport + URL), governed as above. This is
what you can create today.
* **Collection** — synthesizing MCP tools from a KnoxCall **route collection**, so each governed
route becomes a tool executed through the existing proxy. **Not available yet**: the data plane
does not serve this type, so creating one is refused with `422 unsupported_server_type` rather
than leaving you with a server that fails on every call.
**Every tool result is fully inspected.** The gateway talks to your upstream over
Streamable HTTP and returns one complete JSON-RPC response per request, so there is
no partially-inspected streaming path here — unlike `/v1/ai`, where mid-stream
redaction is a real concern with its own
[guarantees](/ai-gateway/streaming-guarantees). Redaction covers the whole result
and error envelope, recursively: string values, object **keys**, and numbers (a
card number encoded as a JSON number does not slip through).
# Migrating from Helicone
Source: https://docs.knoxcall.com/ai-gateway/migrate-from-helicone
Helicone is an observability layer you point a base URL at; KnoxCall is a control plane in the same position. Concept mapping, before/after snippets, and a straight account of what you gain, what you lose, and what to keep running alongside.
# Migrating from Helicone
Helicone's integration is famously one line — change the base URL, keep your SDK —
and KnoxCall's is the same line. That makes the mechanical part of this migration
close to trivial and the interesting part entirely about what sits behind it.
Be clear about the difference before you start, because it decides whether this is
a migration or an addition: **Helicone is an observability layer; KnoxCall is a
control plane.** Helicone tells you what happened. KnoxCall decides what is
allowed to happen, and then tells you what happened.
## Concept mapping
| Helicone | KnoxCall | Notes |
| - | - | - |
| `Helicone-Auth` header + your provider key | A [capability token](/ai-gateway/tokens-and-dpop) and **no** provider key | The big one — see below |
| Custom properties (`Helicone-Property-*`) | Agent `tags` + `X-KC-User` / `X-KC-Team` | Same idea, feeds cost attribution |
| User metrics (`Helicone-User-Id`) | `X-KC-User`, resolved against your SCIM directory | |
| Caching | [Response cache](/ai-gateway/quickstart#response-caching) — `cache_mode` per agent | Off by default, set over the API (no dashboard control yet). `semantic` exists and is opt-in; we publish no hit-rate figure for it |
| Rate limiting | Per-token rate limits + `budget_daily_usd` | Dollars as well as requests |
| Request logging | `ai_gateway_usage` + `api_requests` | Bodies are **not** stored unless you turn body logging on |
| Prompt / session tracking | *(partial)* | Session-level grouping is on the roadmap, not shipped |
| Evaluations, experiments, playground | *(no equivalent)* | Keep Helicone, or another tool, for these |
## Before / after
**Before** — your app holds the provider key and Helicone sees it:
```python theme={"dark"}
client = OpenAI(
api_key=OPENAI_API_KEY, # a real provider key, in your app
base_url="https://oai.helicone.ai/v1",
default_headers={"Helicone-Auth": f"Bearer {HELICONE_API_KEY}"},
)
```
**After** — your app holds neither:
```python theme={"dark"}
client = OpenAI(
api_key=KNOXCALL_CAPABILITY_TOKEN, # not a provider key
base_url="https://acme.knoxcall.com/v1/ai/support-bot/v1",
)
```
That is the whole code change. The provider key lives in KnoxCall, envelope-
encrypted, and is injected at the egress hop — so it is not in your environment,
not in your container, and not in a heap dump.
## What you gain
* **Your workload stops holding a provider key.** A leaked capability token is
scoped to one agent, one model set, one spend cap and one time window, and can
be revoked without rotating anything at the provider.
* **[Mid-stream PII redaction](/ai-gateway/streaming-guarantees).** Entities are
removed inside the SSE stream — including ones split across two frames — without
buffering the response and losing streaming.
* **[A prompt firewall](/ai-gateway/firewall) that can refuse**, plus
[your own scanner](/ai-gateway/guardrail-webhook) if you already run one.
* **Spend caps that actually stop a call**, shared across workers rather than
counted per process.
* **[Routing and fail-over](/ai-gateway/routing-and-failover)** — retries on 429
honouring `Retry-After`, weighted load balancing across providers.
## What you lose, stated plainly
Helicone does things we do not, and pretending otherwise would make the rest of
this page less trustworthy:
* **Evaluations and experiments.** No equivalent. If you run prompt experiments
through Helicone, keep doing that.
* **Prompt management and versioning.** No equivalent.
* **Session-level grouping.** Our usage rows are per call, attributable to a user
and a team, but multi-turn session rollups are not shipped yet.
* **Its dashboard.** Ours is built around cost, protection outcomes and audit
rather than around request exploration.
Running both is entirely reasonable: point your SDK at KnoxCall and forward its
logs onward, or keep Helicone on the experiments you are actively running while
production traffic goes through the gateway.
## A word on timing
Mintlify acquired Helicone (announced 3 March 2026), and
[Helicone's own announcement](https://www.helicone.ai/blog/joining-mintlify) says
its services stay live in maintenance mode: security updates, new models, and bug
and performance fixes keep shipping. We are
not going to build a migration argument on a competitor's health — that ages
badly, and it is the sort of claim that turns out to be wrong in public. Check the
repository and their own communications yourself; if it is thriving, migrate for
the reasons in "What you gain", or do not migrate at all.
## Suggested order
1. Create one agent per model-and-purpose, with a secret holding the provider key.
2. Mint one capability token per calling service, scoped to the models that
service uses.
3. Swap the base URL and drop the `Helicone-Auth` header and the provider key.
4. Rotate the provider key at the provider — it has been in your application's
environment, so treat it as exposed.
5. Turn on redaction and a firewall policy in `warn`, read the events for a few
days, then move to `block`.
Step 4 is the one people skip. The reason to move the key into custody is that it
stops being reachable from your workload — which does nothing for a key that has
already been sitting in a container's environment for a year.
# Migrating from LiteLLM
Source: https://docs.knoxcall.com/ai-gateway/migrate-from-litellm
Point knoxcall ai import at your existing LiteLLM config.yaml and get a dry-run plan: one agent per model_name, load-balanced deployments collapsed into weighted routes, retries and budgets carried across, and your model names preserved as gateway aliases.
# Migrating from LiteLLM
LiteLLM's proxy and KnoxCall's AI Gateway sit in the same place in your stack and
solve overlapping problems, so most of a `config.yaml` maps across mechanically.
There is a command for that.
```bash theme={"dark"}
npm i -g @knoxcall/sdk # the CLI ships with the Node SDK
knoxcall login
knoxcall ai import --from litellm --file ./config.yaml
```
That is a **dry run**. It reads the file, prints exactly what it would create,
and writes nothing — so it needs no sign-in. `--apply` is the deliberate second
step, and it acts as the user signed in with `knoxcall login`.
`ai import` is the one `knoxcall ai` sub-command that exists only in the **Node**
CLI. Everything else (`gateways`, `agents`, `create-agent`, `mint`, `usage`,
`exchange`) is identical in all five SDKs. The importer carries a hand-rolled
YAML-subset parser — `knoxcall` ships with zero runtime dependencies — and four
more hand-ports of a YAML parser would be four more places for the plan to
disagree with itself about what your config means. One implementation, in the CLI
every platform can run.
## What maps onto what
| LiteLLM | KnoxCall |
| - | - |
| `model_list[].model_name` | one agent, slugified — and a gateway **model alias** so your clients keep working |
| `litellm_params.model` (`openai/gpt-4o`) | the agent's `provider` + `default_model` |
| `litellm_params.api_base` | the agent's `upstream` (SSRF-checked at create) |
| several entries sharing one `model_name` | **one** agent with weighted routes |
| `litellm_params.weight` / `rpm` | that route's weight |
| `router_settings.num_retries` | `routing_policy.max_attempts` (+1 — we count attempts, they count retries) |
| `litellm_settings.max_budget` + `budget_duration` | `budget_daily_usd` or `budget_monthly_usd` |
### Load balancing collapses into one agent
This is the mapping worth understanding, because it changes your topology for the
better. In LiteLLM, listing the same `model_name` twice **is** the load balancer:
```yaml theme={"dark"}
model_list:
- model_name: fast
litellm_params: { model: groq/openai/gpt-oss-120b, weight: 3 }
- model_name: fast
litellm_params: { model: together_ai/openai/gpt-oss-120b, weight: 1 }
```
KnoxCall expresses that as one agent with two weighted routes, so it needs **one**
capability token rather than two, and one budget rather than two that have to be
kept in step. See [Routing, Retries & Fail-over](/ai-gateway/routing-and-failover).
### Your model names survive
Existing callers ask for `"model": "fast"`. Rather than make you edit every call
site on day one, the importer writes a gateway-level **model alias** mapping
`fast` → the real upstream id. Aliases resolve *before* the per-agent model
policy, so an alias target still has to pass that agent's allow/deny list.
## What it will not do: your keys
The importer **never reads an API key out of your config**, and never transmits
one. It records how each entry referenced its credential and leaves you to create
the KnoxCall secret:
```
credential (yours to create): os.environ/OPENAI_API_KEY
```
If an entry carries a **literal** key rather than an `os.environ/` reference, the
importer says so and does not read it — and tells you to treat that key as
exposed, because it is sitting in a file. Rotate it at the provider when you
create the KnoxCall secret.
That is not squeamishness. A migration tool that quietly ingests a plaintext key
from a checked-in config has copied that key into a second system, which is an odd
thing for a credential-custody product to do to you on your first day.
## What it will not do: guess
Two refusals you may hit, both deliberate:
**An unprefixed model.** LiteLLM treats `model: gpt-4o` with no provider prefix as
OpenAI. The importer will not inherit that default for you — pointing an agent at
the wrong provider with the wrong key is a worse outcome than a warning. Prefix it
(`openai/gpt-4o`) or set the provider after import.
**A budget duration we do not have.** KnoxCall has a daily cap and a monthly cap.
`budget_duration: 7d` is neither, so the budget is **not** imported and the report
says so. Dividing by seven to fake a daily rate would put a number in front of you
that you never chose.
## The YAML it can read
The importer parses a YAML subset — nested maps, lists of maps, scalars, simple
inline lists — which covers the LiteLLM configs people actually have. It has no
third-party YAML dependency, because `knoxcall` ships with **zero** runtime
dependencies and a credential-handling CLI is the wrong place to grow a
transitive tree.
Anything outside that subset — anchors, aliases, merge keys, block scalars,
multi-document files — is a hard error naming the line:
```
config.yaml: line 14: block scalars (| and >) are not supported
```
It refuses rather than skips, on purpose. An importer that silently dropped a
`model_list` entry would hand you a plan that looks complete and is not; you would
migrate, believe you were done, and find the missing model in production. Being
told about line 14 costs a minute.
## Applying it
This step, and the `create-agent` and `mint` commands after it, act as the user
signed in with `knoxcall login`.
```bash theme={"dark"}
knoxcall ai import --from litellm --file ./config.yaml --apply
```
Creates the gateway, the agents and the model aliases. Exit codes: `0` success,
`1` something failed while applying (partial creation is possible, and every
failure is named so you can retry just those), `2` the input was unusable and
nothing was written.
Then, for each agent, attach its provider key — the importer never read one, so
this is the step that gives the agents an upstream. The key comes from your
environment, never from a flag:
```bash theme={"dark"}
export OPENAI_API_KEY=sk-...
knoxcall ai create-agent --slug gpt-4o --provider openai --secret-from-env OPENAI_API_KEY
knoxcall ai mint --agent
```
At that point you have what LiteLLM gave you, plus per-agent capability tokens,
mid-stream PII redaction, a prompt firewall, and per-call attribution.
## What you gain that has no LiteLLM equivalent
Worth knowing before you plan the cutover, because these are the reasons to do it
rather than the mechanics:
* **Your app never holds a provider key.** It holds a KnoxCall capability token
scoped to a provider, a model set, a spend cap and a time window. The real key
is injected at the egress hop. See [Capability keys](/ai-gateway/tokens-and-dpop).
* **[Mid-stream PII redaction](/ai-gateway/streaming-guarantees)** — entities are
removed inside the SSE stream, including ones split across two frames, without
buffering the response.
* **[A prompt firewall](/ai-gateway/firewall)** that can refuse a request, and
[your own scanner](/ai-gateway/guardrail-webhook) if you already have one.
* **Per-call attribution and audit** — which token, which user, which team, what
it cost.
One thing to check before you cut over: LiteLLM supports more providers than
KnoxCall's fourteen. Run the dry run first and read the warnings — anything that
could not be mapped is named there, and an entry with an `api_base` will usually
map as `openai-compatible` even when its prefix is unknown to us.
# Migrating from Portkey
Source: https://docs.knoxcall.com/ai-gateway/migrate-from-portkey
Concept-by-concept mapping from Portkey's virtual keys, configs and gateway to KnoxCall agents, routing policies and capability tokens — with before/after snippets and an honest account of what does not map.
# Migrating from Portkey
Portkey and KnoxCall overlap on the gateway itself — one endpoint in front of many
providers, with routing, fallbacks and observability — and diverge on where the
provider credential lives and what happens to the data passing through.
There is no automated importer for Portkey today (its configuration lives in a
hosted control plane rather than a file you can hand us), so this is a
concept-by-concept mapping you apply by hand. It is usually an afternoon.
## Concept mapping
| Portkey | KnoxCall | Notes |
| - | - | - |
| Virtual key | Agent + its upstream secret | The agent is the thing you point an SDK at; the secret holds the provider key |
| API key / auth header | [Capability token](/ai-gateway/tokens-and-dpop) | Scoped to provider, model set, spend cap, IP range and time window, HMAC'd into the token |
| Config (routing) | [`routing_policy`](/ai-gateway/routing-and-failover) on the agent | Retries, backoff, weights |
| Config (fallback targets) | `fallback_route_ids` + `route_weights` | Fail-over order, or weighted split |
| Config (retries) | `routing_policy.retry_on` + `max_attempts` | `429` must be named explicitly — see below |
| Metadata | Agent `tags` + `X-KC-User` / `X-KC-Team` headers | Feeds per-user and per-team cost attribution |
| Guardrails | [Prompt firewall](/ai-gateway/firewall) + [PII detectors](/ai-gateway/pii-redaction) + [your own scanner](/ai-gateway/guardrail-webhook) | |
| Budget / usage limits | `budget_daily_usd`, `budget_monthly_usd` | Shared across workers, not per-process |
| Prompt templates | *(no equivalent)* | Keep them in your application |
## Before / after
**Before** — Portkey-style, provider selection in a header or config id:
```python theme={"dark"}
from openai import OpenAI
client = OpenAI(
base_url="https://api.portkey.ai/v1",
api_key=PORTKEY_API_KEY,
default_headers={"x-portkey-config": "pc-my-config-id"},
)
client.chat.completions.create(model="gpt-4o", messages=[...])
```
**After** — the agent *is* the config, so the base URL carries it:
```python theme={"dark"}
from openai import OpenAI
client = OpenAI(
base_url="https://acme.knoxcall.com/v1/ai/support-bot/v1",
api_key=KNOXCALL_CAPABILITY_TOKEN,
)
client.chat.completions.create(model="gpt-4o", messages=[...])
```
Nothing else in your code changes. The routing, the budget, the firewall and the
redaction all hang off `support-bot`, which means they are the same for every
caller of that agent rather than dependent on a header any caller can change.
## Three things that will surprise you
**A 429 is returned to you by default.** Portkey retries on rate limits out of the
box. KnoxCall does not, unless the agent's `routing_policy` names `"429"` in
`retry_on` — because silently absorbing a rate limit into a second provider's
bill is not a decision we make on your behalf. Set it explicitly:
```json theme={"dark"}
{ "routing_policy": { "retry_on": ["429", "5xx"], "max_attempts": 3 } }
```
**Your app stops holding a provider key entirely.** A Portkey virtual key still
resolves to a provider credential that the gateway sends onward; the difference
here is what your workload holds. A KnoxCall capability token is not a provider
credential and cannot be replayed against the provider — it only works against
your agent, and only within its scope.
**Prompt templates have no home here.** If you use Portkey's prompt management,
that stays in your application or in whichever tool you prefer. We are a gateway,
not a prompt store, and pretending otherwise would leave you with half a feature.
## What to do about the acquisition
Palo Alto Networks closed its acquisition of Portkey on 29 May 2026
([press release](https://www.paloaltonetworks.com/company/press/2026/palo-alto-networks-completes-acquisition-of-portkey-to-secure-ai-agents))
and now sells it as Prisma AIRS AI Gateway. Whether that is good or
bad for you depends on whether you wanted a security-platform relationship, and it
is not our place to tell you it is bad. The practical question for a migration is
continuity of the specific features you rely on and of your commercial terms —
worth asking them directly before you plan around either.
## Suggested order
1. Create one agent per Portkey config, with its provider and a secret holding
that provider key.
2. Mint one capability token per calling service — not one shared token. Scope
each to the models that service actually uses. This is the step that pays for
the migration.
3. Move `retry_on` / weights across into `routing_policy`.
4. Run both gateways in parallel behind a feature flag; compare
`ai_gateway_usage` against your Portkey analytics for a week.
5. Turn on [PII redaction](/ai-gateway/pii-redaction) and a
[firewall policy](/ai-gateway/firewall) in `warn` first, read the events for a
few days, then move to `block`.
Step 5 in `warn` first is genuine advice, not a hedge. Turning a blocking control
straight on in front of production traffic is how a good control gets switched
back off after one bad afternoon.
# Model & Output Policy
Source: https://docs.knoxcall.com/ai-gateway/model-and-output-policy
Constrain which models an agent may call (allowlist, denylist, rewrite map, default model) and validate the shape of what comes back against a JSON Schema — block, retry, or warn.
# Model & Output Policy
Two agent-level controls that bracket the call: **model policy** decides what may
go upstream, **output validation** decides whether what came back is usable.
## Model policy
Four fields on the agent, all optional:
| Field | Type | Effect |
| - | - | - |
| `default_model` | string | Substituted when the request body carries no `model`. |
| `model_rewrite` | map | Exact source-model → target-model substitution. |
| `model_denylist` | string\[] | Patterns that are refused. |
| `model_allowlist` | string\[] | When non-empty, the model must match one. |
### Evaluation order
The order matters, because a rewrite changes what the lists see:
1. **No `model` in the body?** If `default_model` is set it is written into the
body and the request proceeds. If it isn't, nothing is enforced — the request
passes through and the upstream decides (most reject a missing model).
2. **`model_rewrite`** — an *exact* key match on the requested name substitutes the
target. The rewritten name is what the lists then match, so you cannot smuggle
a denied model in by rewriting to it.
3. **`model_denylist`** — a match refuses the request. **Denylist beats allowlist**;
a model on both lists is denied.
4. **`model_allowlist`** — when the list is non-empty, a model that matches nothing
in it is refused. An empty allowlist means "no allowlist", not "allow nothing".
### Patterns
Allowlist and denylist entries are globs: `*` matches any run of characters,
including dashes, anywhere in the name — `claude-*` matches every Claude model,
`*-opus-*` matches by family. `?` is a **literal question mark**, not a wildcard.
`model_rewrite` is not a pattern language: its keys are exact model names.
Patterns are compiled with the platform's linear-time regex engine, so a pathological
pattern cannot stall a worker.
**Both lists fail closed, in opposite directions.** A pattern the engine refuses to
compile counts as a **match** on the denylist (so an unreadable deny rule blocks
rather than silently stopping denying) and as a **non-match** on the allowlist (so
an unreadable allow rule grants nothing). Either way the request is refused, never
quietly let through. Check your patterns on save rather than discovering this in
production.
### Responses
A refused model gets a `403` naming the reason and the patterns it could have
matched:
```json theme={"dark"}
{
"error": "model_denylisted",
"error_description": "Requested model is not permitted by this agent's policy",
"requested_model": "gpt-4o",
"allowed": ["claude-sonnet-*", "claude-haiku-*"]
}
```
`error` is `model_denylisted` or `model_not_allowed`. `allowed` is `null` when the
refusal came from the denylist rather than the allowlist. Every data-plane refusal
uses this `{ error, error_description }` shape — it is **not** the `{data, meta}`
envelope the `/v1` management API returns, because the data plane speaks your
provider SDK's protocol, not KnoxCall's. A rewrite or a default
substitution is silent to the caller — the request succeeds — but is recorded on
the usage row, so the model you are billed for is the model that actually ran.
### Day-zero models
Model policy is opt-in. With no allowlist an agent will proxy any model its
upstream accepts, which is what makes a new model usable the day it ships — see
[Providers & day-zero models](/ai-gateway/day-zero-models).
## Output validation
Set an agent's `output_schema` to a JSON Schema and the gateway validates the
model's answer against it before the response leaves the building.
### What is validated
The gateway extracts the assistant's text content from the provider response,
parses it as JSON and checks it. The supported schema subset is deliberately
small — `type`, `required`, `properties`, `items`, `enum` — which covers "return
this exact object shape" without a full JSON-Schema engine.
Two cases pass without complaint by design:
* a response with no extractable text content (nothing to validate);
* content that is not JSON, **unless** the schema's top-level `type` is `object`
or `array` — then non-JSON is a violation.
### Actions
`output_validation_action` decides what happens on a violation. Default is `warn`.
| Action | Behaviour |
| - | - |
| `warn` | The response is returned to the caller unchanged, with `X-Knox-AI-Output-Warning` carrying the first errors (truncated to 200 characters). |
| `block` | The caller gets `422 output_schema_violation` with the first few errors in `error_description`. The upstream call already happened and is still billed. |
| `retry` | The gateway re-calls the upstream **once**, appending the model's own answer plus a corrective instruction naming the schema errors. If the retry succeeds it becomes the response and `X-Knox-AI-Retry: output_schema` is set. If it fails or violates again, the original response is returned **with the `warn` header**. |
A `warn` violation is reported to the caller in a response header only. It does not
write an audit row or a queryable event, so "how often does this agent break its
schema" has no answer on the server side today — read the header in your client if
you need to count them.
A `retry` costs a second upstream call, charged like any other. It is worth it for
a flaky structured-output model and not worth it for a model that never gets the
shape right — fix the prompt instead.
**Output validation runs on the buffered path only.** A streaming request
(`stream: true`) is forwarded token by token and there is no complete document to
validate until the client already has it, so `output_schema` is not applied. If
the response shape is load-bearing for your application, turn streaming off for
that agent (`streaming_enabled: false`) rather than assuming the schema is being
enforced.
A retried response goes through exactly the same downstream stages as a first
response — [PII redaction and detokenization](/ai-gateway/pii-redaction), the
canary scan, the cache-store gate — so a retry cannot be a way around a control
that applied to the first answer.
# Observability
Source: https://docs.knoxcall.com/ai-gateway/observability
Per-request OpenTelemetry spans with GenAI semantic conventions plus KnoxCall AI-Gateway attributes — export to your own backend (Langfuse, Braintrust, Datadog, …). No lock-in, no proprietary eval store.
# Observability
KnoxCall is **OTel-native** for AI observability: every gateway call emits an OpenTelemetry span using the GenAI semantic conventions, plus KnoxCall-specific attributes for the guardrail outcomes. Point it at your own collector and use whatever tracing/eval backend you already run — Langfuse, Braintrust, Datadog, Honeycomb, and so on. There is no proprietary eval store to lock you in.
## Enabling export
Configure the OpenTelemetry OTLP endpoint on the operator **observability** Integration (KnoxCall's config lives in the admin UI, not environment files). Once an endpoint is set, spans export automatically; until then, tracing is off.
## Span attributes
| Attribute | Value |
| - | - |
| `gen_ai.system` | Provider (e.g. `anthropic`, `openai`) |
| `gen_ai.request.model` | Requested model |
| `gen_ai.usage.input_tokens` | Input token count |
| `gen_ai.usage.output_tokens` | Output token count |
| `knoxcall.ai_gateway.firewall_outcome` | `pass` / `warn` / `block` / `tag` |
| `knoxcall.ai_gateway.pii_tokens_swapped` | Count of PII redactions on the call |
| `knoxcall.ai_gateway.cost_usd` | Estimated cost in USD |
## Security and cost events you can alert on
Spans are for tracing a call. The events below are written to your **audit log**,
which is where you set alert rules — `POST /admin/alerts` with
`alert_type: "ai_gateway_event"` and the action in `condition_json.audit_action`.
See [alert rules](/monitoring/alerts) for the rule shape.
| Action | Severity | Raised when |
| - | - | - |
| `ai_gateway.request_denied` | varies | **Any** refusal, on either plane, with the specific cause in the row's details. One action for every denial is deliberate: a new refusal reason cannot invent an action nobody is alerting on. This is the row to alert on if you only alert on one. |
| `ai_gateway.canary_leak` | critical | A [canary token](/ai-gateway/firewall#canary-tokens) injected into a system prompt came back in a model's response — a strong signal the system prompt was extracted. |
| `ai_gateway.pii_tokenize_failed` | critical | Outbound PII tokenization failed, so the request was **refused** rather than forwarded untokenized. The raw value did not leave. |
| `ai_gateway.geo_velocity_anomaly` | scaled | A token was presented from two locations too far apart to be plausible. Severity scales with how implausible the travel is; see [impossible-travel detection](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up). |
| `ai_gateway.spend_spike` | high | A daily spend well above the agent's own robust baseline. See [spend-spike detection](/ai-gateway/budgets-finops#spend-spike-detection). |
| `ai_gateway.unpriced_model` | — | A model was observed that is not in the pricebook, so its calls are recorded `cost_source = 'unpriced'` rather than booked as free. Update the manifest deliberately. |
| `secret_share.domain_violation` | — | A linked secret's domain lock refused an upstream. Written for **both** the borrowing and the lending tenant. |
Configuration changes are audited too, with the same `ai_gateway.` prefix:
`gateway_create` / `_update` / `_archive`, `agent_create` / `_update` /
`_archive`, `token_mint`, `token_revoke`, `firewall_policy_create` / `_update` /
`_delete`, `mcp_server_create` / `_update` / `_archive`, and `mcp_tool_upsert` /
`_update` / `_delete`.
`ai_gateway_event` is not yet in the admin UI's alert-type picker, so these rules
have to be created through the API today.
## Why OTel instead of a built-in eval product
Evals belong where your traces already live. By exporting standard GenAI spans, KnoxCall lets you run evaluation, scoring, and dashboards in the tool your team has chosen, with the guardrail outcomes (firewall verdict, PII redaction count, cost) already attached to each trace — rather than trapping that data in a KnoxCall-only view.
# AI Gateway Overview
Source: https://docs.knoxcall.com/ai-gateway/overview
A security-hardened, provider-neutral proxy for your AI traffic: phantom-token auth, true mid-stream PII redaction, prompt firewall, budgets, and unified cost attribution — with a one-line base-URL swap.
# AI Gateway Overview
The **AI Gateway** is KnoxCall's AI-native egress proxy. It sits between your applications (or agents) and upstream AI providers, and enforces governance and safety on every call — without your code ever holding a raw provider key.
You adopt it with a **one-line base-URL swap**: point your existing Anthropic or OpenAI SDK at your agent's gateway URL and use a short-lived KnoxCall **phantom token** as the API key. Everything else — budgets, PII redaction, the prompt firewall, cost attribution, audit — happens transparently on the wire.
```text theme={"dark"}
Your app / agent ──▶ AI Gateway ──▶ Anthropic / OpenAI
│
├─ phantom-token auth (no raw provider key in your app)
├─ prompt firewall (injection heuristics + canary)
├─ streaming PII redaction (reversible tokenization)
├─ budget enforcement (USD caps, per agent)
└─ usage + cost attribution (per user / team / model)
```
## Why it exists
Handing a raw provider key to an application — or worse, to an autonomous agent — means an unscoped, long-lived, un-revocable credential with no per-call governance. The AI Gateway replaces that with a credential and a control plane designed for AI traffic:
* **Your app never holds a provider key.** It holds a short-lived, scoped, revocable [phantom token](/ai-gateway/tokens-and-dpop). The real provider key lives in a KnoxCall-managed secret, injected by the route at egress.
* **Guardrails run mid-stream, not after the fact.** KnoxCall's [PII redaction](/ai-gateway/pii-redaction) rewrites sensitive data inside the SSE stream as it flows — before it reaches the client — and can restore it on the response. Most gateways can only log or buffer.
* **Cost is attributable and enforceable.** Every call is priced and recorded against a user, team, model, and agent, and [budgets](/ai-gateway/budgets-finops) can warn or hard-block before overspend.
## What you get
| Capability | What it does |
| - | - |
| **Phantom tokens** | Short-lived capability tokens, optionally [DPoP](/ai-gateway/tokens-and-dpop)-bound (sender-constrained). Your app never holds the provider key. |
| **Streaming PII redaction** | Detector stack (Aho-Corasick + regex/checksum + optional [Presidio](/ai-gateway/pii-redaction)) with reversible tokenization, applied inside the live stream. |
| **Prompt firewall** | Built-in prompt-injection heuristics + your own regex/keyword rules + [canary tokens](/ai-gateway/firewall) that detect system-prompt extraction. |
| **Budgets & FinOps** | Per-agent USD caps with warn/block actions; [usage attribution](/ai-gateway/budgets-finops) by user, team, model, and agent. |
| **Compliance packs** | One-click [HIPAA, PCI-DSS, GDPR, SOC 2](/ai-gateway/compliance-packs) recognizer + alert-rule bundles. |
| **Observability** | Per-request [OpenTelemetry GenAI spans](/ai-gateway/observability) — export to your own backend; no lock-in. |
## Core concepts
* **Gateway** — a container for agents, scoped to your tenant. You can have several (e.g. one per product surface).
* **Agent** — the addressable unit at `/v1/ai//…`. An agent has a primary **route** (which upstream provider + injected key), a firewall policy, a PII policy, a budget, and mint-able tokens.
* **Route** — a standard KnoxCall route pointing at the provider (e.g. `api.anthropic.com`), injecting the provider key from a secret. The gateway reuses KnoxCall's whole proxy substrate — secret injection, sandbox, rate limits, audit.
* **Phantom token** — the credential your app sends. Minted for one agent, short-lived, scoped, revocable.
## Bring your own Anthropic key
AI features that use Anthropic (including the gateway route KnoxCall provisions for first-party AI) run on **your own Anthropic key**, configured under **Settings → Integrations → Anthropic** — never a shared KnoxCall key. See [Bring your own key](/ai-gateway/byo-key).
## Next steps
Swap your base URL and make your first governed call in minutes.
How mid-stream redaction and reversible tokenization work.
Phantom-token kinds, scoping, and sender-constrained credentials.
Enforce spend caps and attribute cost by user, team, and model.
# PII Redaction
Source: https://docs.knoxcall.com/ai-gateway/pii-redaction
True mid-stream PII redaction with reversible tokenization: a sliding hold-back FSM rewrites sensitive data inside the live SSE stream, and can restore it on the response.
# PII Redaction
Most gateways can only inspect an AI response *after* it finishes (buffering) or *log* what they saw (monitoring). KnoxCall redacts **inside the live stream**: as SSE frames flow from the provider to your app, sensitive spans are rewritten before they leave the gateway.
## How it works
### The detector stack
Each candidate text span is screened by a layered detector stack, fastest first:
1. **Aho-Corasick** multi-pattern scan for known literals and keywords.
2. **Regex + checksum** validators for structured identifiers — credit cards (Luhn), IBAN, US SSN, email, E.164 phone numbers, JWTs, and known provider API-key shapes.
3. **[Presidio](#presidio-optional)** (optional) — a named-entity recognizer sidecar for names, locations, and other NER-based entities.
Higher-precision tiers win overlap ties, so a span matched by a checksum validator is not double-counted by a looser NER match.
### Streaming hold-back
Streaming redaction uses a **sliding hold-back buffer**: the gateway holds back the last N characters of the stream (default 96) so a sensitive token that straddles two SSE frames is still caught before any part of it is emitted. The buffer is SSE-frame-aware for the Anthropic, OpenAI and Gemini event shapes, so redaction never corrupts the event framing.
The held-back characters are released when the provider marks the block
finished — just before Anthropic's `content_block_stop`, before OpenAI's
`finish_reason` chunk (and always before `data: [DONE]`), and inside Gemini's
`finishReason` chunk — so a client that stops reading at the provider's end
marker has already received the whole answer.
### Streamed tool calls
A tool call's **arguments** are model output too, and they often carry values
lifted straight from the prompt. They are scanned on the streaming path as well:
OpenAI `tool_calls[].function.arguments` (and the older `function_call`),
Anthropic `input_json_delta`, and Gemini `functionCall.args`.
Arguments stream as JSON fragments a few characters at a time, and a fragment
cannot be rewritten without breaking the JSON your client assembles. So the
gateway **holds each call's arguments until the call is finished**, scans every
key and value in the complete document, and releases it as one fragment of the
same call. What changes for you:
* The call's first chunk — its `id` and function `name` — still arrives when the
model starts the call. The arguments arrive **all at once, when the call
finishes**, rather than character by character. A UI that renders arguments as
they stream will show them appear in one step.
* Arguments with nothing detected are passed through **byte for byte**. When
something is redacted, the document is re-serialised (compact whitespace).
* Under `pii_response_mode: "detokenize"`, `KC_*` placeholders inside arguments
are restored like the rest of the response, so the tool you run receives your
original values.
* A system-prompt canary in the arguments is caught before any of them leave; under
a `block` firewall policy the stream ends with an `event: error` frame
(`canary_leak`).
* One call's arguments are held up to 4,194,304 characters. A call larger than
that ends the stream with an `event: error` frame whose `code` is
`tool_arguments_too_large`, rather than being released unscanned.
In `monitor` streaming mode arguments stream through unchanged, as everything
does in that mode, and each call is scanned for the record once it finishes.
### The two directions
Redaction is **two independent settings**, because they answer two different
questions. Attaching a PII policy chooses *what* is detected; these choose *what
happens* to it.
| Setting | Values | Default | What it decides |
| - | - | - | - |
| `pii_request_mode` | `tokenize` \| `off` | `tokenize` | What happens to the **prompt** before it leaves KnoxCall. |
| `pii_response_mode` | `detokenize` \| `redact` | `detokenize` | What happens to the provider's **answer** on the way back. |
`pii_request_mode: "off"` is the only configuration in which the upstream
provider receives a detected value. It exists for agents whose payload has to
arrive byte-for-byte — structured tool-use JSON, for instance, where rewriting a
field would corrupt the tool arguments — and it is a per-agent column so the
choice is visible in the API, the dashboard and a compliance export.
`pii_response_mode: "redact"` still runs the full response-side detector stack:
personal data the *model* produced is redacted, and the canary check still fires.
It differs from `detokenize` only in leaving `KC_*` placeholders in place rather
than restoring your originals.
### Knowing whether the answer was changed
When the response-side detectors change a **non-streamed** answer, the response
carries `X-Knox-AI-Pii-Rewritten: `, where `` is the number of
replacements that actually altered the text. Its presence means the body you
received is **not** the provider's verbatim answer.
* A match that leaves the text unchanged — a monitor-only or allow-listed
detection — is not counted, so detection can find something and the header
still be absent.
* On a non-streamed response, absence means no detector replacement changed
the answer.
**A streamed response never carries this header, even when it was rewritten.**
Headers are sent before the first byte of the body, and streamed redaction
happens mid-stream (see [Streaming hold-back](#streaming-hold-back)), so the
count does not exist yet when the headers go out. On a stream, the header's
absence tells you **nothing** — do not read it as "unmodified". What was
redacted on a streamed request is recorded in [PII events](#pii-events).
Until 2026-09-07 these were **one** boolean, `pii_detokenize_response`, and it
was the response-side one. Request-side tokenization was gated on it, so an agent
with a PII policy attached and `pii_detokenize_response: false` loaded its
recognizers, validated them, refused on a degraded policy — and then sent the
prompt to the provider verbatim. Existing agents were migrated to
`pii_request_mode: "tokenize"`, including the ones that had detokenize off:
prompts on those agents are now tokenized where they previously were not.
`pii_detokenize_response` is still accepted on the API and is now a read-only
mirror of `pii_response_mode == "detokenize"`.
### Streaming: what has a mid-stream redactor, and what refuses
The hold-back rewriter can only splice a stream it can parse. KnoxCall ships one
for the **Anthropic, OpenAI and Gemini** SSE shapes. It has none for **Cohere's**
native SSE shape, and none for **Bedrock**, which streams length-prefixed
`application/vnd.amazon.eventstream` binary frames rather than SSE at all.
A streamed request on one of those two is **refused with 400
`streaming_redaction_unsupported`**, not served. Streaming it would hand the
client a response with no response-side redaction, no detokenization and no
canary scan — the exact controls the buffered path applies on every 2xx. Send the
request without `stream: true`.
The same refusal applies **per endpoint** on a provider that does have a
redactor. Each redactor reads one event family, so streaming is offered only
where that family is what the provider sends:
| Wire shape | Endpoint that streams | Refused when streamed |
| - | - | - |
| Anthropic | `…/v1/messages` | anything else |
| OpenAI and OpenAI-compatible | `…/chat/completions` | the Responses API (`/v1/responses`), legacy `/v1/completions`, anything else |
| Gemini | `…:streamGenerateContent` (the gateway adds `?alt=sse`) | anything else |
What counts as a streamed request is decided by the **request**: `stream: true`
in the body, or Gemini's `:streamGenerateContent` endpoint. An
`Accept: text/event-stream` header on its own asks nothing of the provider, so
the response is served on the buffered path and fully redacted. An agent with
`streaming_enabled: false` refuses a request that asks to stream
(`400 streaming_disabled`) — the provider would stream regardless, and a
streamed body cannot be redacted on the buffered path. And a streamed request
that the provider answers with something other than an event stream is refused
(`502 upstream_stream_shape_unexpected`) rather than forwarded unread.
### A body the gateway cannot read is refused
Tokenization walks the prompt fields of a JSON body. A non-JSON body has no
prompt fields to walk, so on an agent with `pii_request_mode: "tokenize"` it is
**refused with 400 `pii_unscannable_body`** rather than forwarded. "We found no
personal data" and "we could not look" are different answers, and only one of
them belongs in a compliance record. Set `pii_request_mode: "off"` if the agent
genuinely needs to forward opaque payloads.
### Reversible tokenization
When a policy uses reversible tokenization, each detected span is replaced with a stable placeholder token, and the original value is stored in a per-conversation **encrypted token map** (`ai_gateway_pii_token_map`). On the response path, the gateway can **detokenize** — restoring the original values for the end user while keeping them out of the provider's logs and out of any [cache](/ai-gateway/observability).
Scope the map across turns with the `X-KC-Conversation-Id` request header, so a value tokenized on turn 1 restores consistently on turn 5.
Send a **UUID** and it is used as the map's id exactly as given. Send **any other string** — `support`, `case-4192` — and it is treated as a name and scoped to the **phantom token that sent it**: two tokens using the name `support` get two separate maps, so one caller's values can never restore into another's. If you want several tokens to share one map, generate a UUID and send that.
#### Inspecting a conversation's token map
For debugging, an **Owner** or an **Admin** can list a conversation's tokens with
`GET /admin/ai-gateway-pii/token-map/{conversationId}` — the conversation's UUID, or the name a
client sent plus `?principal=`. Each entry shows the token, the first three
characters of the original value and its length; the full value never leaves the gateway. Three
characters and an exact length still narrow a card number, an SSN or an email a long way, so this
view is a **reveal**: every request asks for a fresh second factor (an authenticator code, a
passkey or a security key; never an emailed code), writes a `plaintext.reveal` entry to the audit
log naming the conversation **before** anything is decrypted, and counts against the same
per-person reveal budget as showing a secret's value. If the audit entry cannot be written, or
the budget cannot be counted, the request is refused rather than served. Editors and Viewers
cannot use it, and a platform operator who is not a member of your workspace is refused.
Because reversible tokenization keeps a per-conversation map, it interacts with caching: semantic caching is disabled while reversible tokenization is active for a conversation, to avoid leaking one conversation's values into another.
## Measured detection quality
We measure the detector stack against a committed labeled corpus rather than
describing it. The corpus (`tests/fixtures/guardrail-eval/`) holds 38 documents
carrying 70 labeled identifiers, plus 28 **hard negatives** that contain no
personal data at all - UUIDs, git object ids, non-Luhn order numbers, stack
traces, Kubernetes manifests, an IBAN with a wrong check digit. The numbers below
are produced by `npm run ai-gateway:eval`, pinned in
`tests/coverage/baselines/guardrail-eval.json`, and enforced on every CI run by a
ratchet: recall may only rise, and the false-positive rate may only fall.
Measured 2026-08-25, in-process stack (Aho-Corasick + regex/checksum), no Presidio:
| Entity | Labeled | Precision | Recall |
| - | - | - | - |
| US SSN | 5 | 100% | 100% |
| Payment card (Luhn) | 6 | 100% | 100% |
| IBAN (mod-97) | 3 | 100% | 100% |
| Email address | 8 | 80% | 100% |
| E.164 phone | 4 | 100% | 100% |
| JWT | 2 | 100% | 100% |
| Anthropic API key | 3 | 100% | 100% |
| OpenAI API key | 2 | 100% | 100% |
| AWS access key id | 2 | 100% | 100% |
| Google API key | 1 | 100% | 100% |
| Stripe secret/restricted key | 2 | 100% | 100% |
| GitHub token | 2 | 100% | 100% |
| Canary token | 2 | 100% | 100% |
| Tenant custom literal | 3 | 100% | 100% |
Every detected span had **exact** boundaries: nothing was over- or under-redacted.
**False positives on clean text: 1 of 28 hard-negative documents (3.6%).** The
one failure is worth naming, because it is the failure mode that costs you
something: the email pattern matches machine identities, so `git@github.com` and
`deploy@build-runner-04.internal` in a pasted shell command are redacted. If you
send code through an agent with redaction on, expect that.
### What this stack does not detect
The in-process tiers cover structured identifiers with a deterministic shape.
They do **not** detect names, street addresses, medical record numbers, national
provider or DEA identifiers, passport numbers, driver licences, IP addresses or
crypto wallet addresses. Those entities are in the corpus too, and score 0%
against this stack - deliberately, so the gap is a published number rather than
an omission. Names and locations need the [Presidio](#presidio-optional) tier;
the rest are covered by [compliance packs](/ai-gateway/compliance-packs) once a
pack is installed.
### Prompt-injection firewall
The [firewall](/ai-gateway/firewall) heuristics are measured the same way, over
24 injection attempts and 16 benign prompts. Measured 2026-08-25: **recall 58%,
precision 67%**. The built-in rules catch direct override phrasings ("ignore all
previous instructions", "your new instructions are", named jailbreaks) and miss
obfuscated, translated, and indirectly-framed attacks. **Its false-positive rate
on benign prompts is 44%** - the built-in rules are keyword-shaped, so an
ordinary prompt about enabling developer mode on a phone, or a colleague named
Dan, matches. Treat the built-ins as a signal to tag or warn on; scope a
`block`-action policy to rules you have measured against your own traffic.
## Presidio (optional)
For NER-based entities, add a **Presidio** sidecar as a tenant Integration:
1. Deploy the Presidio sidecar (see `docker/presidio-sidecar/`).
2. In **Settings → Integrations → Presidio**, set the sidecar URL, timeout, and score threshold.
The gateway resolves the sidecar per tenant from your Integration config (never an environment variable), and merges Presidio matches into the sync detector stack on the **buffered outbound request path**.
**A configured analyzer that cannot run is never silent.** Three cases count as "cannot run": the URL is refused by the egress check (a private, loopback, link-local or cloud-metadata address, unless you set `allow_private_host` on a self-hosted install); its circuit breaker is open after five consecutive failures; or **the call itself failed on this request** — a timeout or an error, which can happen before the breaker opens. In all three:
* **With a PII policy attached** — `503 pii_policy_degraded`. Requests are refused rather than forwarded, for the same reason a recognizer that will not compile refuses: the policy you attached is only partly in force. The in-process tiers do not detect names, addresses or medical record numbers, so losing the analyzer is losing most of what the policy was for.
* **With no policy attached** — the request is served, but carries `X-Knox-AI-Pii-Degraded: presidio_url_refused` (or `presidio_circuit_open`, or `presidio_call_failed` for a per-request failure) and writes a `detector_unavailable` row to the [PII events](#pii-events) ledger. Nothing was asked to be enforced, so it is not a refusal — but you find out here, not later.
A per-request failure is **retryable**: it says this call did not get NER coverage, not that the analyzer is down. Under an attached policy the `503` is the signal to retry.
Having **no** analyzer configured is a different thing and stays silent: nothing was asked for, so nothing is missing.
A single mid-scan failure that has not yet tripped the breaker is still absorbed (the sync tiers cover the request). Only the two cases above are surfaced today.
### Where the sidecar may live
The analyzer receives **the prompt text being inspected**, so its URL is treated as an egress destination and validated on every call: the hostname is resolved, every returned address is checked against the private/reserved ranges, and the request is sent to the address that was checked (so a name that flips after validation cannot be reached). Non-`http(s)` schemes are refused.
* On the **cloud** service the analyzer must be a publicly-routable host. A private-network URL is refused when you save it (`presidio_url_blocked`); an existing one stops being used, and the stack runs on its built-in tiers.
* On a **self-hosted** deployment, tick **“Analyzer is on this deployment’s private network”** on the same Integration to allow a sidecar on `10.x`, `192.168.x`, `172.16.x`, an IPv6 ULA, or `127.0.0.1`. It is off by default and ignored on the cloud service.
* Link-local addresses (`169.254.0.0/16`, `fe80::/10` — the cloud metadata range) are **never** permitted, with or without that setting.
## PII events
Every redaction (and every skipped detection in monitor mode) is written to the `ai_gateway_pii_events` ledger with the entity type, detector, direction (request/response), and action. [Compliance-pack](/ai-gateway/compliance-packs) alert rules can count these — e.g. *"alert when an SSN appears in a response"* — and they feed [evidence exports](/ai-gateway/compliance-packs).
## Batch requests
Prompt inspection follows the request shape, not the endpoint. A batch submit is
an ordinary POST whose body nests each real request one level down, so the
gateway walks into it:
```json theme={"dark"}
{"requests":[{"custom_id":"a","params":{"model":"…","messages":[{"role":"user","content":"…"}]}}]}
```
Every sub-request's prompt text is tokenized, firewall-scanned and checked
against the agent's model allow/deny lists — the same controls, reading the same
fields, as a single request. The tokenizer and the firewall read one shared
enumeration (`src/ai-gateway/prompt-shapes.ts`), so they can never disagree about
what the prompt is:
* Anthropic `system` (string and block-array) and `messages[].content`
* OpenAI `input`, `instructions` and legacy `prompt`
* Gemini `contents[].parts[].text` and `systemInstruction`
* Cohere `message`, `preamble` and `chat_history[].message`
* RAG grounding `documents[]` (bare strings, and `text` / `snippet` / `title`)
* Tool **descriptions**, on all four declaration shapes — a description is prompt
text the model reads verbatim on every turn, and it is where a real example
("look up a patient by SSN, e.g. …") tends to get pasted
Tool *schemas* (`parameters`, `input_schema`) are deliberately not rewritten:
the provider validates against them, they carry field names rather than values,
and corrupting one breaks the tool call without protecting anything. If one
sub-request names a model the agent may not use, the **whole batch is refused**:
accepting it partially would hand you a batch id that does not describe what you
submitted.
### OpenAI's file-based batches
OpenAI's Batch API does not carry your prompts. You upload them to the Files API
first and submit `{"input_file_id": "file-…"}`, so the gateway never sees the
text.
If your agent has PII tokenization or a prompt firewall configured, that submit
is **refused with 422 `batch_input_not_inspectable`**. The alternative would be
to forward it with both controls silently inert — a request that looks protected
and is not.
Two ways forward:
* Use the **Anthropic Message Batches** API, which carries its requests inline
and is inspected in full.
* Turn the control off for that agent, if the batch genuinely carries no
sensitive data — an explicit choice, recorded on the agent.
## Configure a policy
PII policies and recognizers are managed on the **PII tab** of the gateway detail page. A policy selects which entity types to detect and the default action for a match.
Attaching a policy to an agent selects **what is detected**. It does not by itself decide what happens to a match — that is [`pii_request_mode` and `pii_response_mode`](#the-two-directions) on the agent, which default to `tokenize` and `detokenize`. An agent with no policy still runs the built-in detector tier in both directions.
Attaching a policy also makes the gateway fail **closed** on it: if the policy cannot be loaded, does not resolve for your tenant, or can only be partly applied (a recognizer that will not compile, a `presidio_*` rule with no sidecar, more recognizers than the active limit), the request is refused with `503 pii_policy_unavailable` or `pii_policy_degraded` rather than forwarded under a policy that is not fully in force.
They are also a first-class part of the API, so the whole thing is scriptable.
## Scripting it end to end
A policy is a named bundle of **recognizers** plus a `default_action`. An agent
points at one through `pii_redact_policy_id`. Everything below is on
`/v1/ai-gateway/*` and needs only a management API key — no browser step.
```bash curl theme={"dark"}
# 1. A recognizer for a shape we invented and no built-in tier knows about.
# Try the pattern first — this saves nothing.
curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-recognizers/test \
-H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \
-d '{"pattern":"MRN-[0-9]{7}","text":"chart MRN-1234567 attached"}'
# → {"data":{"matched":true,"matches":[{"span":[6,17],"matched":"MRN-1234567", …}]}, …}
RECOGNIZER=$(curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-recognizers \
-H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"medical_record_number","kind":"regex","pattern":"MRN-[0-9]{7}","action":"redact"}' \
| jq -r .data.id)
# 2. A policy bundling it. Every id here must be a recognizer you own.
POLICY=$(curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-policies \
-H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"HIPAA\",\"recognizer_ids\":[\"$RECOGNIZER\"],\"default_action\":\"redact\"}" \
| jq -r .data.id)
# 3. Attach it to an agent at create time — or PATCH an existing one.
curl -sX POST "https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY/agents" \
-H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"Clinical assistant\",\"slug\":\"clinical\",\"provider\":\"anthropic\",
\"upstream_secret_id\":\"$SECRET\",\"pii_redact_policy_id\":\"$POLICY\"}"
```
```javascript Node theme={"dark"}
import { KnoxCall } from "@knoxcall/knoxcall";
const kc = new KnoxCall({ apiKey: process.env.KNOXCALL_API_KEY });
// Compile-and-scan on the server, with the engine the data plane runs.
const preview = await kc.aiGateway.testPiiRecognizer({
pattern: "MRN-[0-9]{7}",
text: "chart MRN-1234567 attached",
});
console.log(preview.matched, preview.matches[0].matched); // true 'MRN-1234567'
const recognizer = await kc.aiGateway.createPiiRecognizer({
name: "medical_record_number",
kind: "regex",
pattern: "MRN-[0-9]{7}",
action: "redact",
});
const policy = await kc.aiGateway.createPiiPolicy({
name: "HIPAA",
recognizer_ids: [recognizer.id],
default_action: "redact",
});
const agent = await kc.aiGateway.createAgent(gatewayId, {
name: "Clinical assistant",
slug: "clinical",
provider: "anthropic",
upstream_secret_id: secretId,
pii_redact_policy_id: policy.id,
});
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
kc = KnoxCall(api_key=os.environ["KNOXCALL_API_KEY"])
preview = kc.ai_gateway.test_pii_recognizer(
pattern=r"MRN-[0-9]{7}", text="chart MRN-1234567 attached"
)
assert preview["matched"]
recognizer = kc.ai_gateway.create_pii_recognizer(
name="medical_record_number", kind="regex",
pattern=r"MRN-[0-9]{7}", action="redact",
)
policy = kc.ai_gateway.create_pii_policy(
name="HIPAA", recognizer_ids=[recognizer["id"]], default_action="redact",
)
agent = kc.ai_gateway.create_agent(
gateway_id, name="Clinical assistant", slug="clinical",
provider="anthropic", upstream_secret_id=secret_id,
pii_redact_policy_id=policy["id"],
)
```
### Four refusals worth knowing before you script against them
**Test the pattern on the server, not locally.** Patterns are compiled with a
linear-time engine. Lookahead, lookbehind and backreferences — which every
client-side regex engine accepts — are rejected. A local preview would show you
matches for a recognizer that can never run, and the recognizer would then be
skipped at scan time. A skipped detector fails **open**.
**An empty `recognizer_ids` means *every* enabled recognizer**, not none. It is
the "use everything I own" setting. Passing `[]` on an update is therefore a
widening, not a clearing.
**A recognizer id you do not own is a `400 recognizer_not_found`** at write
time, on create and on update. The column has no foreign key, and the loader
intersects with the recognizer's own tenant — so a stored foreign id would give
you a policy that lists three recognizers and runs zero. That is
indistinguishable from working redaction right up until the PII reaches the
provider, which is why it is refused rather than accepted.
**Deletes are refused while something still points at the row.** `DELETE` a
policy an agent still references and you get `409 policy_in_use`: the foreign
key is `ON DELETE SET NULL`, so the delete would quietly detach every bound
agent and turn redaction off for each of them. `DELETE` a recognizer a policy
still lists and you get `409 recognizer_in_use` — dropping the id would widen
the policy, per the rule above. Detach first, deliberately.
# AI Gateway Quickstart
Source: https://docs.knoxcall.com/ai-gateway/quickstart
Route your first AI call through the gateway with a one-line base-URL swap — Anthropic or OpenAI SDK, a phantom token, and every guardrail on by default.
# AI Gateway Quickstart
You adopt the AI Gateway by pointing your existing AI SDK at your agent's gateway URL and using a **phantom token** as the API key. No new SDK, no code rewrite.
## 1. Create an agent
In the dashboard, go to **AI Gateway → New agent**. Pick your provider — Anthropic, OpenAI, Google Gemini, Cohere, Azure OpenAI or a self-hosted Ollama-compatible endpoint — paste that provider's API key, and press **Create agent**. KnoxCall does the rest in one step: it encrypts the key into a normal KnoxCall secret, creates the upstream route that injects it, creates the gateway if you do not have one yet, creates the agent, and mints your first capability key.
Azure OpenAI and self-hosted endpoints also ask for the base URL of your own resource, because that address is specific to you.
When the agent is created you get two things:
* an **agent URL** — `https://.knoxcall.com/v1/ai/` (sandbox: `https://sandbox-.knoxcall.com/...`)
* a **phantom token** — `kp_live_a_…` (shown once at mint time)
The final step also shows the environment variables, the curl command and the SDK snippet already filled in with that token, and a **Send test request** button that runs one prompt through the agent you just created.
The provider key you paste is envelope-encrypted with your tenant key before it is stored, and no endpoint ever returns it. It is injected at the upstream hop, so clients holding the capability key never see it.
Prefer to wire it up piece by piece? **New Gateway** still creates an empty gateway, and the agent form still lets you point at an existing secret instead of pasting a key.
The data plane is served on your tenant's **proxy subdomain** (`.knoxcall.com`), not `api.knoxcall.com`. Always use the `agent_url` returned at agent creation as your base URL.
## 2. Swap your base URL
### Anthropic SDK
```python theme={"dark"}
from anthropic import Anthropic
client = Anthropic(
base_url="https://acme.knoxcall.com/v1/ai/support-bot", # your agent URL
api_key="kp_live_a_...", # your phantom token
)
msg = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Summarize this ticket…"}],
)
print(msg.content[0].text)
```
### OpenAI SDK
```python theme={"dark"}
from openai import OpenAI
client = OpenAI(
base_url="https://acme.knoxcall.com/v1/ai/support-bot",
api_key="kp_live_a_...",
)
resp = client.chat.completions.create(
model="gpt-5",
messages=[{"role": "user", "content": "Summarize this ticket…"}],
)
print(resp.choices[0].message.content)
```
### Node / TypeScript
The provider's own Node SDK works unchanged — same swap, same two lines:
```typescript theme={"dark"}
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
baseURL: 'https://acme.knoxcall.com/v1/ai/support-bot', // your agent URL
apiKey: process.env.KNOXCALL_AI_TOKEN, // your kp_ phantom token
});
const msg = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Summarize this ticket…' }],
});
console.log(msg.content[0].text);
```
For OpenAI, `import OpenAI from 'openai'` and pass the same `baseURL` / `apiKey`.
### curl
```bash theme={"dark"}
curl https://acme.knoxcall.com/v1/ai/support-bot/v1/messages \
-H "Authorization: Bearer kp_live_a_..." \
-H "content-type: application/json" \
-d '{
"model": "claude-sonnet-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}'
```
### What comes back
The provider's own response shape, unchanged — the gateway does not wrap it:
```json theme={"dark"}
{
"id": "msg_01XyZ…",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [{ "type": "text", "text": "The customer is reporting…" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 27, "output_tokens": 112 }
}
```
What the gateway adds is in the **headers**, not the body:
```http theme={"dark"}
X-Request-Id: 0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35
X-Knox-AI-Budget-Pct: 12.4
```
`X-Request-Id` is on every response and is the key into your audit trail for that
exact call. The rest appear only when there is something to say — see
[the header reference](#useful-requestresponse-headers) below. A refusal replaces the body with
an [error envelope](/ai-gateway/errors); nothing is ever silently degraded.
That's it. The call is now authenticated by the phantom token, screened by the [prompt firewall](/ai-gateway/firewall), [PII-redacted](/ai-gateway/pii-redaction) if a policy applies, counted against the agent's [budget](/ai-gateway/budgets-finops), and recorded with per-call [cost attribution](/ai-gateway/budgets-finops).
## 3. Authentication schemes
The phantom token can be sent three ways — use whichever your SDK makes easiest:
```http theme={"dark"}
Authorization: Bearer kp_live_a_...
x-api-key: kp_live_a_...
x-knox-ai-key: kp_live_a_...
```
For [DPoP](/ai-gateway/tokens-and-dpop)-bound tokens, also send a `DPoP` proof header on every request.
## Everything you need is on the agent's Connect panel
You do not have to keep the values from the create wizard. Open the agent
(**AI Gateway → your gateway → the agent**) and the **Connect** panel at the top
of the page has:
* the **agent URL**, copyable — the base URL your SDK points at;
* the **environment variables** for that agent, already filled in
(`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` and the key variable your provider's
SDK reads), so a working shell is one paste away;
* a **curl smoke test** shaped for that agent's provider;
* the newly issued **capability key**, the one time it is shown, when you mint
from the Capability keys list below it;
* a **Playground** button, which runs a prompt through this agent's real data
plane and shows what the gateway did to it.
The panel derives the provider from the agent's route, so the snippets are for
the provider that agent actually calls — not a guess from the model name.
A capability key is displayed **once**, at mint time. If you lose it, revoke it
and mint another; there is no endpoint that returns an existing token's value.
## 4. Streaming
Streaming works unchanged — set `stream: true` (an `Accept: text/event-stream` header alone does not stream). PII redaction runs **inside** the stream, so redactions appear as the tokens arrive rather than after the response completes:
```python theme={"dark"}
with client.messages.stream(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": "…"}],
) as stream:
for text in stream.text_stream:
print(text, end="")
```
## Try it from the dashboard: the Playground
Every agent has a **Playground** (agent page → *Playground*). It sends the prompt
you type through that agent's own `/v1/ai/` data plane — the same URL your
SDK uses — with a one-shot capability key that expires the moment it is used, and
shows what the gateway did to the call beside the reply:
* **the redaction diff** — your prompt with every detected value highlighted, next
to the exact text the provider received, with the format-preserving surrogates
that replaced them;
* **the firewall verdict** for the prompt;
* **cache outcome, model, latency and cost** for the request.
Those numbers are read back from the rows that request wrote (`ai_gateway_usage`,
`ai_gateway_firewall_events`) and the PII token map for its conversation — not
recomputed for display. Where a row is missing the panel says so rather than
showing a reassuring default. The Playground spends the same provider tokens a
real call does, and is subject to whatever budget the agent has: a quickstart
agent starts with none, so set one on the agent before handing the Playground to
a wider team.
## From the command line
The KnoxCall CLI carries an `ai` command group over the same `/v1` control plane,
so gateways, agents, minting and spend are all reachable without opening the
dashboard. It reuses the credential `knoxcall login` writes — no keys in scripts,
and the tenant is resolved from the token, so no tenant header. The token acts
with your role: `ai mint` (and creating or changing gateways and agents) needs an
owner or admin, exactly as in the dashboard, while the read commands work for any
member:
```bash theme={"dark"}
knoxcall ai gateways # id · slug · name
knoxcall ai agents # that gateway's agents
knoxcall ai mint --kind agent \
--name "CI pipeline" # shown once
knoxcall ai usage --period 30d # cost + tokens by model
knoxcall ai usage --period 7d --agent
```
**The `ai` command group ships in the `knoxcall-cli` package, which is not
published to a registry yet.** The `knoxcall` binary that comes with the language
SDKs today covers `login`, `logout`, `whoami` and `init` — not `ai`. Until
`knoxcall-cli` is published, use the `/v1` endpoints directly; the commands above
map one-to-one:
| Command | Endpoint |
| - | - |
| `ai gateways` | `GET /v1/ai-gateway/gateways` |
| `ai agents ` | `GET /v1/ai-gateway/gateways/{gatewayId}/agents` |
| `ai mint ` | `POST /v1/ai-gateway/agents/{agentId}/tokens` |
| `ai usage` | `GET /v1/ai-gateway/usage` |
## Useful request/response headers
| Header | Direction | Meaning |
| - | - | - |
| `X-KC-User` | request | the person's directory key for per-user cost attribution — SCIM `externalId` (else `userName`), or a member's KnoxCall email ([details](/ai-gateway/budgets-finops)) |
| `X-KC-Conversation-Id` | request | conversation id — scopes the reversible PII token map **and the response cache** across turns |
| `X-Request-Id` | response | UUID for this call (appears in audit logs) |
| `X-Knox-AI-Budget-Pct` | response | current budget utilization % |
| `X-Knox-AI-Tools-Stripped` | response | tools removed by the agent's tool allowlist |
| `X-KC-Team` | request | cost-centre override — the UUID of a group your workspace has declared; see [attribution](/ai-gateway/budgets-finops#the-x-kc-team-override) |
| `X-Knox-AI-Cache` | response | present on a cache hit (`exact` or `semantic`) |
| `X-Knox-AI-Attribution` | response | why an attribution claim was not used: `user_unregistered`, `team_malformed`, `team_unknown` (comma-separated when more than one applies) |
| `X-Knox-AI-Output-Warning` | response | the agent's [output schema](/ai-gateway/model-and-output-policy#output-validation) was violated and the action is `warn` |
| `X-Knox-AI-Retry` | response | `output_schema` — the answer you are reading is a schema-corrected retry |
| `X-Auth-Failure` | response | on a 401, the machine-readable refusal code (e.g. `step_up_required`, `oneshot_already_used`) |
## Response caching
Caching is **off by default** and is set per agent with `cache_mode` — in the
dashboard on the Agent's **Budgets & cache** tab, or over the `/v1` Management
API. An agent's `cache_mode` is `off` unless you set it deliberately.
| Mode | Behaviour |
| - | - |
| `off` (default) | No caching. |
| `exact` | A byte-identical request replays the saved response. |
| `semantic` | A request that *means the same thing* as a prior one replays it, matched by embedding cosine similarity above `cache_similarity_threshold` (default **0.95**). Falls back to exact matching when it cannot embed. |
A cache hit costs **\$0**, is recorded with `cost_source = 'cache_hit'`, does not
charge the agent's budget, and sets `X-Knox-AI-Cache: exact` or `semantic`.
### What the hit rate actually is
The Agent's **Budgets & cache** tab reports, over the last 30 days: hit rate,
exact hits, near (semantic) hits, misses, bypassed requests, stored entries, and —
for an agent in `semantic` mode — how many requests approximate matching actually
ran for, its own hit rate, and how many degraded, **with the reason**.
Two things about that report are worth knowing before you read a number off it.
**Bypassed is not a miss.** A request that streamed, that hit a response-direction
guardrail hook, that ran on an agent with a customer-managed key, or that the
gateway could not attribute to a token, was never eligible for the cache. Those
are counted beside the rate and never inside it. An agent whose traffic is all
`stream: true` will show a large bypass count and no hit rate, which is the
truthful answer rather than 0%.
**Before 2026-09-08 this number was wrong** — for an agent in `exact` mode it read
100% from its first hit onward, because misses were never recorded. If you looked
at it before that date, look again.
**The number we have, and what it is worth.** On our reference fixture
(`support-assistant-v1`: 7 conversations, 30 requests, a mix of verbatim repeats
and paraphrases) at the default `0.95` threshold, **13.3% of requests were served
from cache** — three exact, one approximate — with no false matches and no
cross-caller hits. At `0.90` it is 30%; the exact-cache floor, which needs no
embedding model at all, is 10%.
That is a **fixture** measurement produced with a substitute embedder, because our
test suite may never call a provider. It is not a forecast of your traffic, and we
will not dress it up as one: how much a response cache saves depends almost
entirely on how repetitive your prompts are. The number that matters is the one on
your own Agent's cache panel.
### Isolation — what a hit can and cannot cross
Every cache entry is keyed on **the phantom token that produced it and the
conversation id**, in addition to the agent and the request body. That means:
* a cached response **never crosses tenants**;
* it **never crosses tokens**, even inside one tenant — two users of the same
agent do not serve each other's answers, whatever conversation ids they send;
* it **never crosses conversations** for one token;
* a request the gateway cannot attribute to a token neither reads nor writes the
cache at all.
The token is the boundary that carries the weight, because it is the identity
KnoxCall verifies. `X-KC-Conversation-Id` is a client header — still worth making
unguessable and unique per end-user conversation, since it partitions a single
token's own cache, but it is no longer the only thing standing between two
callers.
### Semantic matching — the requirements
Semantic matching runs only when **all four** of these hold (`cache/index.ts`,
`semanticEnabled`). Each one is a boundary, not a limitation to work around:
1. `cache_mode` is `semantic` **and** `cache_embedding_model` is set.
2. The request carries an **`X-KC-Conversation-Id`**. Approximate matching needs an
isolation boundary to be confined to; a stateless request has none, so it is
exact-only.
3. The agent is **not** using reversible PII tokenization — that is,
`pii_request_mode` is `off` **and** `pii_response_mode` is `redact`. Either
half on its own is enough to keep semantic matching off. Conversation scoping
already makes it safe, but approximate matching against a per-conversation
token map is held apart until a dedicated review clears it. Tokenizing agents
still get exact caching.
4. The upstream exposes an **OpenAI-compatible `/v1/embeddings`** on the same
origin as the chat endpoint. Anthropic has no embeddings API, so an
Anthropic-backed agent is exact-only.
When embedding is unavailable the request degrades cleanly to an exact-cache miss —
never an error on the data plane.
Caching applies to **buffered** responses only. A streaming request is never served
from cache and never stored, so an agent whose traffic is all `stream: true` will
see no hits.
## Next steps
Why AI features use your own Anthropic key, and how to add it.
Mint scoped, sender-constrained tokens for agents and CI.
Pin which models an agent may call, and the shape of what comes back.
The ceilings the data plane enforces, and what we do and don't promise.
# Routing, Retries & Fail-over
Source: https://docs.knoxcall.com/ai-gateway/routing-and-failover
Retry on 429 with Retry-After, exponential backoff with jitter, weighted load-balancing across providers, and gateway-wide model aliases — configured per agent, enforced in the data plane.
# Routing, Retries & Fail-over
Your application points at one agent URL. What happens behind it — how many
providers, which one served this call, what happened when the first one said
"slow down" — is the agent's configuration, not your code.
## The default
Every agent starts with `routing_policy: {}`, and that means:
* **one attempt** per route, no retries;
* **fail over on 5xx** to `fallback_route_ids`, in order;
* everything else — 429, 4xx, 2xx — returned to you unchanged.
That is deliberate, and it is what an agent does until you ask for something
else. In particular a **429 comes straight back to you**: rate limits are
information your client may want to act on, and silently absorbing them into a
second provider's bill is not a decision the gateway makes for you.
## Retrying a rate limit
The one you almost certainly want:
```json theme={"dark"}
{
"routing_policy": {
"retry_on": ["429", "5xx"],
"max_attempts": 3,
"backoff_ms": 250,
"backoff_multiplier": 2,
"max_backoff_ms": 5000
}
}
```
Now a 429 is retried against the same route — honouring the provider's own
`Retry-After` header — and if the route is still rate-limited after
`max_attempts`, the request moves to the next candidate.
`Retry-After` is honoured in **one direction only**: it can extend a wait, never
shorten one. A provider saying "retry after 1s" while the gateway is already
backing off 3.2s does not get hammered at 1s. Both
[RFC 9110 forms](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after) are
understood — delta-seconds and an HTTP-date.
**Jitter is on by default.** Each delay is spread across 50–100% of its computed
value, so a fleet of workers that all hit the same rate limit at the same moment
does not retry in lockstep and re-trip it. Set `"jitter": false` if you need a
deterministic schedule for a test.
### Timeouts
`"retry_on": ["timeout"]` covers a transport failure — connection reset, DNS
failure, the upstream hanging up. It is separate from `5xx` because they call
for different appetites: a 502 usually means "this provider is having a bad
minute", a connection reset often means "that one packet was unlucky".
## Weighted load-balancing
Two keys on the same provider, or two providers entirely, split by weight:
```json theme={"dark"}
{
"routing_policy": {
"route_weights": {
"": 3,
"": 1
}
}
}
```
75% of requests are served by the first route, 25% by the second — and the one
not chosen is still in the fail-over order behind it, so a weight spreads load
without giving up redundancy.
Three rules worth knowing:
* **A route you do not name keeps weight 1.** Adding a weight for one route does
not silently drain the others.
* **Weight 0 drains a route completely** — no ordinary traffic and no fail-over
traffic. That is what "park this provider" means; if you wanted "try it last",
give it weight 1 against a much larger one.
* **Your token's scope still wins.** The capability scope on the phantom token is
checked against *every* candidate before it is dialled, so a weight can never
reach a route the token was not granted, and neither can a fail-over. Same for
Live/Test: a `kp_test_` token resolves only sandbox routes at every hop.
## Model aliases
Set once on the gateway; every agent under it honours them:
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY_ID \
-H "Authorization: Bearer $KNOXCALL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"model_aliases": {"fast": "claude-haiku-4-5", "flagship": "claude-opus-5"}}'
```
Your services then ask for `"model": "fast"` and you change what that means in
one place. Chains work (`cheap` → `fast` → a real id); a cycle resolves back to
the model you asked for rather than looping.
Aliases resolve **before** the agent's model policy, which is the important part:
an alias target still has to pass that agent's allowlist and denylist. If the
resolution ran the other way round, `{"safe-model": "the-one-you-banned"}` would
be a one-line way around your own denylist.
## What it recorded
Every usage row now carries what actually happened:
| Column | Meaning |
| - | - |
| `served_route_id` | The route that produced the response — not necessarily the agent's primary, once weights or fail-over are in play. |
| `attempts` | Total upstream dispatches for the request, including the first. `1` is the ordinary case. |
Both are `NULL` for a cache hit, because nothing was dispatched. `attempts > 1`
is the signal worth alerting on: it means a provider is making you pay for the
same answer twice.
## The limits, and why they exist
Every field is clamped server-side:
| Field | Ceiling |
| - | - |
| `max_attempts` | 5 |
| any single delay | 30s |
| a honoured `Retry-After` | 60s |
| total sleep for one request | 60s |
| any `route_weights` value | 1000 |
These are not tuning suggestions. A request waiting to retry is holding an API
worker, so an agent configured with `max_attempts: 10000` and
`backoff_ms: 600000` would be a self-inflicted outage — and a hostile upstream
answering `429` with `Retry-After: 86400` would be someone else's. The ceilings
are what make "configure your own retries" safe to offer at all.
`max_attempts` is also forced back to 1 whenever `retry_on` is empty, so a stored
policy never claims a retry that cannot happen. An unrecognised key inside
`routing_policy` is rejected with a 400 naming the field rather than stored and
ignored — a `retryOn` typo that reads as configured and does nothing is worse
than an error.
# Streaming Guarantees
Source: https://docs.knoxcall.com/ai-gateway/streaming-guarantees
Three streaming PII-redaction modes — holdback (mid-stream), buffer (full-buffer), monitor (passthrough) — with a published latency benchmark whose relative guarantees are enforced by CI.
# Streaming Guarantees
Most gateways can only inspect a streamed AI response *after* it finishes, or *log* what they saw. KnoxCall redacts **inside the live SSE stream**. This page states the per-mode guarantee and publishes the latency cost — a number competitors who buffer or don't enforce mid-stream can't put on the table.
## What in a stream is scanned
Redaction applies to the model's **text output**, in every content block a
response carries — not just the first:
| Provider | Scanned |
| - | - |
| Anthropic | `text_delta` and `thinking_delta` (extended-thinking reasoning), per content-block index |
| OpenAI / Azure OpenAI / Ollama | `delta.content` and `delta.refusal`, for **every** choice when `n > 1` |
| Gemini | every candidate, and every text part within a candidate |
| Cohere | no SSE strategy — Cohere agents are called buffered, so the response is scanned as a whole |
Each block gets its own hold-back window, so text from one completion is never
spliced into another's.
**Not scanned: streamed tool-call arguments** — `delta.tool_calls[].function.arguments`
(OpenAI) and `input_json_delta` (Anthropic). They are JSON fragments arriving a
few characters at a time, and splicing a replacement mid-fragment produces a
document your client cannot parse, so redacting them needs a different mechanism
than the hold-back window. If your agent passes sensitive values into tool
arguments, use the [MCP gateway](/ai-gateway/mcp-gateway) — which *does* redact
tool arguments recursively — or turn streaming off for that agent so the whole
response is scanned as one document.
**Extended thinking and `signature`.** Redacting reasoning text means the
`signature` Anthropic attaches to a thinking block no longer matches its
contents, so replaying that block verbatim on a later turn will be rejected. This
is not new behaviour on the streaming path — the buffered path has always scanned
the whole response body — and the alternative is streaming your users' data in
the clear. Re-prompt rather than replaying thinking blocks through the gateway.
## The three modes
Each agent picks a `pii_streaming_mode` (default `holdback`).
| Mode | Client-visible guarantee | First-byte latency | Use when |
| - | - | - | - |
| **holdback** (default) | PII is redacted **inside the stream** using a sliding hold-back tail (`pii_streaming_holdback_chars`, default 96) so an entity that straddles two SSE frames is still caught before any part of it is emitted — including its first half. | Near-instant — tokens flow as they arrive. | The default. Real-time UX with real protection. |
| **buffer** | The **entire** response is accumulated, redacted once, then emitted. Zero chance a cross-frame entity slips through, at the cost of first-byte latency (the client waits for the whole message). | Deferred — first byte after the full response arrives. | Maximum assurance; non-interactive/batch calls. |
| **monitor** | The stream passes through **unredacted** for lowest latency, but every entity is still detected and written to the [PII event ledger](/ai-gateway/pii-redaction) for alerting and evidence. | Passthrough — no added first-byte latency. | You need visibility, not enforcement (e.g. observing before you turn redaction on). |
All three record detections; only `holdback` and `buffer` alter the bytes the client sees.
## Why holdback is the hard one (and the differentiator)
A sensitive token can arrive split across SSE frames — `"...SSN 123-"` in one delta, `"45-6789..."` in the next. A naïve mid-stream redactor emits the first half before it can tell it's PII. KnoxCall's hold-back FSM keeps the last N characters buffered until enough context has arrived to decide, then splices any redaction back into the SSE frame and re-serialises it — so the client never sees a partial entity, and framing is never corrupted. Bedrock punts (`stream:false` required); Cloudflare buffers; Portkey's streaming guardrails are log-only. KnoxCall enforces.
## Published benchmark
`scripts/bench-ai-gateway-streaming.ts` drives a representative Anthropic response (120 text deltas seeded with email/SSN/credit-card entities) through each mode and a raw passthrough baseline, and records **p50/p95 TTFB** (time-to-first-emitted-byte) and **total drain time**. The snapshot lives at `scripts/bench-ai-gateway-streaming.results.json`.
This is a CPU-bound micro-benchmark of the redaction Transform itself (no network) — it isolates the overhead the gateway *adds* on top of the hundreds of milliseconds an LLM already takes. **Representative** numbers from one run on a developer machine:
| Mode | TTFB p50 | Total p50 | TTFB overhead vs passthrough |
| - | - | - | - |
| passthrough | \~0.01 ms | \~0.03 ms | — |
| **holdback** | **\~0.03 ms** | \~0.9 ms | **\~0.02 ms** |
| buffer | \~0.24 ms | \~0.37 ms | \~0.23 ms |
| monitor | \~0.01 ms | \~0.32 ms | \~0.00 ms |
The headline: **mid-stream redaction (holdback) reaches first byte in a small fraction of the time full-buffering does**, and the absolute cost is far below the hundreds of milliseconds an LLM already takes. You get real, enforced, mid-stream PII protection essentially for free on the latency budget.
## What CI actually enforces
Absolute milliseconds depend on the machine, so we do not assert them — a guard that fails because a runner was busy is a guard that gets deleted. What **is** enforced, on every commit, by `tests/ai-gateway-streaming-latency-invariants.test.ts` under plain `npm test`, driving the same harness this benchmark uses:
* **holdback emits to the client before the upstream stream has ended** — the "redacts inside the live stream" claim reduced to an ordering of two events, with `buffer` as the negative control. No timing involved, so it cannot flake and cannot be satisfied by a fast full-buffer.
* **holdback's TTFB is a small fraction of buffer-mode's**, and its overhead over raw passthrough stays under a generous ceiling.
* **monitor is indistinguishable from passthrough** on first-byte latency and is byte-identical on the wire.
* **holdback and buffer actually redact** the planted email, SSN and the credit-card number that is deliberately *split across four SSE frames* — checked on the reassembled text the end user reads, not on the wire bytes. Without this, a redactor that did nothing would post the best latency numbers in the file.
* **the committed snapshot satisfies the same relative invariants**, so `--write` cannot commit numbers that disagree with the code.
Re-generate the snapshot with `npx tsx scripts/bench-ai-gateway-streaming.ts --write`. Absolute numbers will differ on your hardware; the orderings above will not.
## The hold-back window
`pii_streaming_holdback_chars` (default 96) is the sliding tail. It must be at least as long as the entity you expect, or that entity can never sit inside the window at once. Values below the default are **raised to 96 automatically** rather than honoured: a shorter window silently stops detecting anything longer than itself while still reporting redaction as enabled, and the window costs nothing at first byte (see the benchmark above) — so there is no trade to make. Raise it (256 is a reasonable setting for name/address-style entities) rather than lowering it.
Entities **longer than the window** are handled separately. Four detector patterns are open-ended — JWTs and the Anthropic/OpenAI, Stripe and GitHub key shapes — and none of them matches until the whole value has arrived, so the window alone cannot protect them: a real JWT is 200–800 characters. The FSM therefore also recognises a *possible prefix* of one of these anchored at the buffer's end and holds from there, releasing when the value completes and is redacted. Holding is bounded (a few KB per stream); past that bound the gateway redacts what it has rather than hold more, so the identifying head of a credential is never emitted in the clear.
## Configure the mode
Set it on the agent — via the **PII tab** on the gateway detail page, or over the API:
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/ \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"pii_streaming_mode": "buffer"}'
```
# Tokens & DPoP
Source: https://docs.knoxcall.com/ai-gateway/tokens-and-dpop
Phantom capability tokens: short-lived, agent-scoped, revocable credentials — with token kinds, IP/CIDR scoping, DPoP sender-constraint, and OIDC workload federation for CI.
# Tokens & DPoP
Your application authenticates to the gateway with a **phantom token** — a capability token minted for one agent. It is short-lived, scoped, and revocable, and it stands in for the real provider key (which never leaves the server).
## Token shape
```text theme={"dark"}
kp___<32hex>_
```
* `kp_` — the phantom-token family prefix. It is **disjoint** from every other
KnoxCall credential: `kc_` (OAuth 2.1 access), `kr_` (OAuth 2.1 refresh), `tk_`
(tenant API key) and `AKE` (enterprise access key). See
[the 2026-09-01 changelog entry](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix)
— this prefix changed, with no compatibility window.
* `env` — `live` or `test`, e.g. `kp_live_a_…` and `kp_test_a_…`.
* `kind` — the capability class (below).
* The trailing segment is a CRC over everything before it, so a mistyped token is
rejected without a database lookup.
* The token is validated by a capability HMAC bound to your tenant master key, so a token minted for one tenant can't be replayed against another.
**The environment segment is enforced, not cosmetic.** A `kp_test_` token resolves
only sandbox routes and a `kp_live_` token only Live ones — the segment is part of
the route lookup on the primary route *and* on every failover route, so a 5xx
cannot become a way for a Test token to reach a Live upstream. A request whose
environment cannot be proven is refused rather than assumed to be Live.
## Token kinds
| Kind | Prefix | Use |
| - | - | - |
| **agent** | `kp_live_a_…` | Full agent access — the normal application/agent credential. |
| **read** | `kp_live_r_…` | Read-oriented calls. |
| **tool** | `kp_live_t_…` | Tool-bound access, intended to be scoped to an allowlisted tool set. |
| **oneshot** | `kp_live_o_…` | Single-use — atomically consumed on first use (ideal for a playground or a hand-off). |
### One-shot tokens and rejected requests
A one-shot token is consumed at the END of authentication — after the token
hash, expiry, capability HMAC, IP/CIDR window and DPoP proof have all passed, so
a request rejected by any of those does **not** burn it.
It is consumed **before** any gateway *policy* runs. A one-shot call that is then
refused by the model policy, the tool allowlist, the prompt firewall, the budget
or the per-call token cap has still spent the token: you get the 4xx explaining
the refusal, and retrying the same token returns `401 oneshot_already_used`
instead of the original reason.
That ordering is deliberate. If the consume waited until after policy, two
concurrent replays of one token would both pass authentication, both run the
pipeline and both reach the provider — spending real money twice — and the loser
would only be told afterwards. A token burned by its own rejected request costs
one re-mint; a token replayable during policy evaluation is not single-use at
all.
**Practical rule:** mint a one-shot per *attempt*, not per *intent*. If your
client retries, mint again.
## Scoping
A token carries a **capability scope** — a small JSON object, HMAC-signed with
your tenant master key, so it cannot be edited without the key. Every key below
is enforced; the two marked *at authentication* are checked by the auth
middleware, the rest need the resolved request (its model, tools and route) and
so are enforced in the data plane, before the upstream is dialled.
| Key | Enforced | Refusal |
| - | - | - |
| `ip_cidrs` | at authentication | `403 ip_cidr_mismatch` |
| `valid_window` (`days`, `hours`) | at authentication | `403 outside_valid_window` |
| `providers` | per request | `403 provider_not_in_token_scope` |
| `models` | per request | `403 model_not_in_token_scope` |
| `tools` | per request | `403 tool_not_in_token_scope` |
| `egress_routes` | per request, **including every failover hop** | `403 route_not_in_token_scope` |
| `max_cost_per_day_usd`, `max_tokens_per_day` | per request | `429 token_scope_limit_exceeded` — see [Budgets](/ai-gateway/budgets-finops#caps-carried-by-the-token-itself) |
Expiry is separate from the scope: every token can carry a TTL and an expired
token is rejected at the edge.
A **malformed** restriction is a refusal, never a pass. `{"models": "claude-haiku-4"}`
(a string where a list is required) and `{"models": []}` (a list that restricts to
nothing) both refuse the request with `403 malformed_token_scope`, because
treating either as "unrestricted" would silently invert the most restrictive
value into the most permissive one.
**There is no mint API that sets a scope yet.** `POST /v1/ai-gateway/agents/{agentId}/tokens`
and its admin twin both stamp an **empty** scope deliberately: neither offers a
parameter to set one, and an earlier revision guessed a `providers` restriction
from the model name — which mislabelled every Gemini, Mistral and Cohere agent
and would have refused the token's own traffic. An empty scope means "no
token-level restriction", which is the truth.
The one path that produces a scoped token today is an **OIDC binding**, where you
author the scope on the binding. A binding whose scope narrows nothing is now
refused at write time, because an unnarrowed scope mints an unrestricted token.
An explicit `scope` parameter on mint is planned and not shipped; it needs the
Postman and five-SDK fan-out that every `/v1` change requires.
Revoke a token at any time from the **Tokens tab** on the gateway detail page, or
with `DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId}`; revocation takes
effect immediately.
### Gateway-level (agentless) tokens
Not every phantom token belongs to an agent. A token can be minted at the
**gateway** level — it has no `agent_id` — which is how a credential is issued
for a whole gateway rather than one agent's traffic.
Because such a token is invisible to every agent-scoped route, `/v1` carries two
endpoints that address the gateway instead:
* `GET /v1/ai-gateway/gateways/{gatewayId}/tokens` — lists the gateway's tokens,
agent-scoped **and** agentless.
* `DELETE /v1/ai-gateway/gateways/{gatewayId}/tokens/{tokenId}` — revokes any of
them, including an agentless one that no agent endpoint can reach.
There is no `POST` here: nothing mints a gateway-level token on request. The one
thing that produces one is the [OIDC exchange](#oidc-workload-federation-ci-without-static-secrets)
when the request carries an RFC 8707 `resource` naming an MCP server — the
resulting token is kind `tool`, confined to exactly that one MCP resource by its
capability HMAC, and **refused outright on `/v1/ai`**, which requires an agent it
does not have. If you find an agentless token you cannot account for, this is the
endpoint that revokes it.
## DPoP (sender-constrained tokens)
Set `dpop_required: true` on a token to make it **sender-constrained** per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). The client generates a key pair and sends a `DPoP` proof JWT on every request; the gateway binds the token to that key's thumbprint (`jkt`) and rejects the token if presented without a matching proof.
This means a leaked token is useless without the corresponding private key — the single most effective mitigation for the "unscoped env-var key" problem that dominates agent deployments. DPoP nonces are tracked in a durable, cross-worker store so replayed proofs are rejected even behind multiple workers.
```http theme={"dark"}
POST /v1/ai/support-bot/v1/messages
Authorization: Bearer kp_live_a_...
DPoP: eyJ0eXAiOiJkcG9wK2p3dC...
```
## OIDC workload federation (CI without static secrets)
CI systems and workloads can exchange a short-lived OIDC **id\_token** (e.g. a GitHub Actions token) for a scoped phantom token via [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, so no long-lived secret is stored in the pipeline:
```http theme={"dark"}
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
```
An **OIDC binding** (configured on the agent's Federation section) pins the trusted issuer, audience, and required attribute conditions (for example, a specific repository), and mints a token only when the presented id\_token matches. Subject tokens are single-use — a replayed id\_token is rejected.
## Impossible-travel detection and step-up
Set a gateway's `geo_velocity_action` to catch a token used from two places too far
apart to be the same holder (a leaked credential in use elsewhere):
| Action | What happens |
| - | - |
| **off** (default) | No geo check. The lookup is never made. |
| **warn** | Classify the request's location against the token's last-used location and emit an alertable `ai_gateway.geo_velocity_anomaly` audit event. Alert-only — the token keeps working. |
| **block** | Everything `warn` does, **plus**: when the travel is classified anomalous or impossible, flag the token for **step-up**. Its NEXT use is refused. |
### What `block` actually does
`block` never revokes a token and never fails the request that triggered it. The
geo lookup is an external call made off the hot path, so the *current* request is
already on its way upstream by the time the classifier has an answer. What `block`
does is set `step_up_required` on the token row, and the **next** presentation of
that token is refused at authentication:
```http theme={"dark"}
HTTP/1.1 401 Unauthorized
X-Auth-Failure: step_up_required
{
"error": "step_up_required",
"error_description": "Re-authenticate to continue — an unusual location change was detected on this token.",
"code": "step_up_required"
}
```
Match on the `code` field (or the `X-Auth-Failure` header), not the message.
`error` carries the same value — see [Errors](/ai-gateway/errors). Before
2026-09-08 this refusal answered `{error: "Unauthorized", code, reason}`; `code`
is unchanged, and `reason` is now `error_description`.
The refusal happens before the agent is loaded, so no upstream call is made and
nothing is charged.
This is the safe form of "block", and the reason it is not revoke: a false-positive
`impossible` classification — a VPN hop, a mobile carrier's NAT, a stale geo-IP
row — would otherwise lock a legitimate user out of a credential they cannot get
back. Forcing re-authentication is something the real holder can do and a thief
holding only the bearer token cannot.
### Recovering from a step-up
Two ways back:
1. **Mint a fresh token** for the agent and reconfigure the consumer. This is the
correct response when you cannot rule out that the token leaked.
2. **Clear the flag**, when you have confirmed a false positive (a shared machine
key used across regions, a CI runner that moved provider):
```http theme={"dark"}
POST /admin/ai-gateway/{gatewayId}/tokens/{tokenId}/clear-step-up
X-Tenant-ID:
```
Owner/admin only, tenant-scoped, and it only affects a token that is currently
flagged — a call for any other token returns 404. The token resumes working
immediately with its original scope and expiry.
Clearing a step-up is available on the tenant admin API only. There is no `/v1`
equivalent and no button in the admin UI yet, so today this is an API call or a
re-mint. Prefer the re-mint unless you are confident the flag was spurious —
clearing it puts a token back in service that the gateway told you it could not
account for.
### Operational notes
* The check **skips the geo lookup entirely when the source IP is unchanged**, which
short-circuits the overwhelming majority of requests, and the per-gateway action
is cached for 30s.
* Every stage fails **open** on error: a geo provider timeout, an unresolvable IP or
a failed write costs one comparison, never access. A gateway whose action cannot
be read is treated as `off`.
* The first request from a token has no prior sample to compare against, so a token
is never flagged on its first use.
* Because the geo lookup is an external call, enable it deliberately (and pair it
with a local geo database at high request volumes).
* `warn` and `block` both emit `ai_gateway.geo_velocity_anomaly`, with severity
scaled by how implausible the travel is. Watch it with an
[alert rule](/monitoring/alerts) on that audit action.
**Phantom tokens do not refresh, and there is no refresh-token rotation.** An
earlier revision of this page said a rotator and an `ai_gateway.refresh_theft`
event existed in the codebase but were unserved. Both were **deleted** — the
table they read was never written by anything, so a reviewer grepping for the
detector would have found a wired dispatcher and concluded the detection existed.
Reporting a signal that cannot fire is worse than reporting none.
A phantom token expires and you mint another. Mint with a short TTL (a token with
no explicit expiry now defaults to **30 days**, clamped between 60 seconds and 90
days), and for CI use the
[OIDC exchange](#oidc-workload-federation-ci-without-static-secrets) — which mints
a fresh short-lived token per run and refuses a replayed subject token — rather
than storing one.
## Mint tokens
Mint from the **Tokens tab** on the gateway detail page, or programmatically via the control-plane mint endpoint. The plaintext token is shown **once** at mint time — store it securely.
# Get Account Info
Source: https://docs.knoxcall.com/api-reference/account/get
Returns your tenant's account details, including subscription plan and billing period
## Get Account Info
```http theme={"dark"}
GET /v1/account
```
Returns your tenant's account details, including subscription plan and billing period.
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"slug": "acme-corp",
"name": "Acme Corporation",
"region": "us-east",
"subscription_plan": "pro",
"subscription_status": "active",
"trial_start_at": "2026-01-01T00:00:00.000Z",
"trial_end_at": "2026-01-14T23:59:59.000Z",
"subscription_current_period_start": "2026-03-01T00:00:00.000Z",
"subscription_current_period_end": "2026-03-31T23:59:59.000Z",
"subscription_cancel_at": null,
"created_at": "2026-01-01T00:00:00.000Z"
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
```bash cURL theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl https://api.knoxcall.com/v1/account \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
resp = requests.get(
"https://api.knoxcall.com/v1/account",
headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"}
)
account = resp.json()["data"]
print(f"Plan: {account['subscription_plan']}, Status: {account['subscription_status']}")
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/account", {
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
const { data: account } = await resp.json();
console.log(`Plan: ${account.subscription_plan}, Status: ${account.subscription_status}`);
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Account not found |
# Get Current Usage
Source: https://docs.knoxcall.com/api-reference/account/usage
Returns your current resource usage and plan limits for the current billing period
## Get Current Usage
```http theme={"dark"}
GET /v1/account/usage
```
Returns your current resource usage and plan limits for the current billing period. Use this to monitor how close you are to hitting plan limits before creating new resources.
### Response
```json theme={"dark"}
{
"data": {
"billing_period": {
"year": 2026,
"month": 3,
"start": "2026-03-01T00:00:00.000Z",
"end": "2026-03-31T23:59:59.000Z"
},
"api_calls": {
"used": 12847,
"limit": 100000,
"percentage": 12.85
},
"resources": {
"routes": { "used": 8, "limit": 50 },
"secrets": { "used": 12, "limit": 100 },
"clients": { "used": 5, "limit": 25 },
"environments": { "used": 3, "limit": null }
},
"plan": "pro",
"status": "active"
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
A `limit` of `null` means the resource is unlimited on your current plan.
```bash cURL theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl https://api.knoxcall.com/v1/account/usage \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
resp = requests.get(
"https://api.knoxcall.com/v1/account/usage",
headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"}
)
usage = resp.json()["data"]
print(f"API calls: {usage['api_calls']['used']}/{usage['api_calls']['limit']}")
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/account/usage", {
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
const { data: usage } = await resp.json();
console.log(`API calls: ${usage.api_calls.used}/${usage.api_calls.limit}`);
```
# Agents (v1)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents
List, create, read, update, and archive AI Gateway agents over the public /v1 Management API.
# Agents
An **agent** belongs to a gateway and defines how one class of AI traffic is proxied: the upstream route, default model and model policy, budgets, streaming, and firewall / PII policies. Once created, an agent is served on the data plane at `/v1/ai/{slug}` — see [Execute AI Request](/api-reference/ai-gateway/execute) to send it traffic.
These endpoints are the public `/v1` control-plane equivalent of the dashboard agent pages. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the response envelope, pagination, and error types — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there.
`/v1` never exposes KnoxCall-managed system agents (for example the Workflows system agent). Reads, updates, token operations, and deletes against a system agent return `404 not_found`.
## The agent object
An agent is returned with its full configuration. The most commonly used fields:
| Field | Type | Description |
| - | - | - |
| `id` | string (uuid) | Agent identifier. |
| `gateway_id` | string (uuid) | Parent gateway. |
| `name` | string | Display name. |
| `slug` | string | URL-safe handle, unique per tenant. The agent is served at `/v1/ai/{slug}`. |
| `description` | string \| null | Free-text description. |
| `primary_route_id` | string (uuid) \| null | Upstream route the agent proxies to. |
| `default_model` | string \| null | Default model when the request does not specify one. Drives the phantom-token scope. |
| `model_allowlist` | string\[] | Allowed models (empty = no allowlist restriction). |
| `model_denylist` | string\[] | Denied models. |
| `budget_daily_usd` | string \| null | Daily spend cap in USD (numeric, returned as a string). |
| `budget_monthly_usd` | string \| null | Monthly spend cap in USD. |
| `streaming_enabled` | boolean | Whether SSE streaming is allowed. Defaults to `true`. |
| `firewall_policy_id` | string (uuid) \| null | Attached prompt-injection firewall policy. |
| `pii_redact_policy_id` | string (uuid) \| null | Attached PII redaction policy. |
| `tool_allowlist` | string\[] | Tool names permitted through the agent. |
| `status` | string | One of `active`, `paused`, `archived`. |
| `created_at` / `updated_at` | string (ISO 8601) | Timestamps. |
The object also includes the remaining agent settings (`fallback_route_ids`, `model_rewrite`, `budget_overage_action`, `pii_detokenize_response`, `pii_streaming_holdback_chars`, `cache_mode`, `output_schema`, `output_validation_action`, and others) at their configured or default values.
## List agents
```http theme={"dark"}
GET /v1/ai-gateway/gateways/{gatewayId}/agents
```
Returns the agents in a gateway, paginated (`page`, `per_page`). Requires the `read` capability. Returns `404 not_found` if the gateway does not belong to your tenant.
```bash cURL theme={"dark"}
curl "https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10/agents" \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
gw = "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10"
resp = requests.get(
f"https://api.knoxcall.com/v1/ai-gateway/gateways/{gw}/agents",
headers={"Authorization": f"Bearer {TOKEN}"},
)
agents = resp.json()["data"]
```
The response is a paginated list of agent objects wrapped in `{ data, meta }`.
## Create an agent
```http theme={"dark"}
POST /v1/ai-gateway/gateways/{gatewayId}/agents
```
Requires the `write` capability.
**Request body**
| Field | Type | Description |
| - | - | - |
| `name` | string | **Required.** Display name. |
| `slug` | string | **Required.** 2–64 lowercase alphanumerics/hyphens; unique per tenant. Becomes the data-plane path `/v1/ai/{slug}`. |
| `description` | string | Optional. |
| `primary_route_id` | string (uuid) | Optional. Upstream route to proxy to. Must belong to your tenant. |
| `default_model` | string | Optional default model. |
| `model_allowlist` | string\[] | Optional allowed-model list. |
| `model_denylist` | string\[] | Optional denied-model list. |
| `budget_daily_usd` | number | Optional daily spend cap. |
| `budget_monthly_usd` | number | Optional monthly spend cap. |
| `streaming_enabled` | boolean | Optional. Defaults to `true`. |
| `firewall_policy_id` | string (uuid) | Optional firewall policy. Must belong to your tenant. |
| `pii_redact_policy_id` | string (uuid) | Optional PII redaction policy. Must belong to your tenant. |
`primary_route_id`, `firewall_policy_id`, and `pii_redact_policy_id` must reference resources owned by your tenant. A reference to another tenant's resource (or a malformed id) returns `400 invalid_reference`.
```bash cURL theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10/agents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Support Bot",
"slug": "support-bot",
"description": "Handles tier-1 support questions",
"default_model": "claude-sonnet-4-6",
"budget_daily_usd": 10.00,
"budget_monthly_usd": 200.00,
"streaming_enabled": true
}'
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
gw = "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10"
resp = requests.post(
f"https://api.knoxcall.com/v1/ai-gateway/gateways/{gw}/agents",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"name": "Support Bot",
"slug": "support-bot",
"default_model": "claude-sonnet-4-6",
"budget_daily_usd": 10.00,
"budget_monthly_usd": 200.00,
},
)
agent = resp.json()["data"]
```
**Response** — the created agent wrapped in `{ data, meta }`:
```json theme={"dark"}
{
"data": {
"id": "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f",
"tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"gateway_id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10",
"name": "Support Bot",
"slug": "support-bot",
"description": "Handles tier-1 support questions",
"primary_route_id": null,
"fallback_route_ids": [],
"model_allowlist": [],
"model_denylist": [],
"default_model": "claude-sonnet-4-6",
"model_rewrite": {},
"budget_daily_usd": "10.00",
"budget_monthly_usd": "200.00",
"budget_per_call_max_tokens": null,
"budget_overage_action": "block",
"pii_redact_policy_id": null,
"pii_detokenize_response": true,
"pii_streaming_holdback_chars": 96,
"cache_mode": "off",
"streaming_enabled": true,
"firewall_policy_id": null,
"tool_allowlist": [],
"output_schema": null,
"output_validation_action": "warn",
"status": "active",
"paused_reason": null,
"created_at": "2026-07-06T12:00:00.000Z",
"updated_at": "2026-07-06T12:00:00.000Z",
"created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
Missing `name` or `slug` returns `400 validation`; an invalid slug returns `422 invalid_slug`; a duplicate slug returns `409 conflict`.
## Get an agent
```http theme={"dark"}
GET /v1/ai-gateway/agents/{agentId}
```
Returns a single agent. Requires the `read` capability. Returns `404 not_found` if the agent does not belong to your tenant or is a system agent.
```bash theme={"dark"}
curl https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \
-H "Authorization: Bearer $TOKEN"
```
## Update an agent
```http theme={"dark"}
PATCH /v1/ai-gateway/agents/{agentId}
```
Partial update — send only the fields you want to change. Requires the `write` capability. The `slug` cannot be changed.
**Updatable fields**
`name`, `description`, `primary_route_id`, `default_model`, `model_allowlist`, `model_denylist`, `budget_daily_usd`, `budget_monthly_usd`, `streaming_enabled`, `firewall_policy_id`, `pii_redact_policy_id`, `pii_detokenize_response`, `tool_allowlist`.
Reference fields (`primary_route_id`, `firewall_policy_id`, `pii_redact_policy_id`) are tenant-ownership checked exactly as on create.
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"default_model": "claude-opus-4-6",
"budget_daily_usd": 25.00,
"model_denylist": ["gpt-4o"]
}'
```
The response is the updated agent wrapped in `{ data, meta }`.
## Archive an agent
```http theme={"dark"}
DELETE /v1/ai-gateway/agents/{agentId}
```
Soft-deletes (archives) the agent and stops it serving data-plane traffic. Requires the `write` capability.
```bash theme={"dark"}
curl -X DELETE https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \
-H "Authorization: Bearer $TOKEN"
```
**Response**
```json theme={"dark"}
{
"data": {
"id": "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f",
"status": "archived"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
# Create Agent
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-create
POST /ai-gateway/gateways/{gatewayId}/agents
Create an AI agent within a gateway, bound to a provider and upstream secret, with optional default model and budget configuration.
The `/v1` operation above is the one to write against — it is what the five SDKs,
the Postman collection and this spec cover. Most of an agent's configuration is
applied **after** creation with
[Update agent](/api-reference/ai-gateway/agents-update); see
[the agent object](/api-reference/ai-gateway/agents#the-agent-object) for the full
field list.
## The dashboard twin
### Create agent
```http theme={"dark"}
POST /admin/ai-gateway/{gatewayId}/agents
```
The same operation exists on the **admin host** (`admin.knoxcall.com`), where the dashboard calls it. Authenticate with a session JWT, send the `X-Tenant-ID` header, and use an owner or admin account — it is **not** an `api.knoxcall.com` API-key endpoint. The fields below describe **that** endpoint.
**Request body**
```json theme={"dark"}
{
"name": "Customer Support Bot",
"slug": "support-bot",
"provider": "anthropic",
"upstream_secret_id": "00000000-0000-0000-0000-000000000000",
"description": "Handles tier-1 support questions",
"default_model": "claude-sonnet-4-6",
"budget_daily_usd": 10.00,
"budget_monthly_usd": 200.00,
"streaming_enabled": true
}
```
**Create fields**
| Field | Type | Description |
| - | - | - |
| `name` | string | **Required.** Display name for the agent. |
| `slug` | string | **Required.** URL slug; the agent is served at `/v1/ai/{slug}`. |
| `provider` | string | **Required** (unless you pass `primary_route_id`). Any id in the server-side catalog — fourteen at the time of writing: `anthropic`, `openai`, `gemini`, `cohere`, `azure-openai`, `ollama`, `groq`, `together`, `mistral`, `deepseek`, `fireworks`, `xai`, `bedrock`, `openai-compatible`. Determines the upstream URL and the auth header template. An unknown value returns a 400 naming the valid set, so read that rather than mirroring this list in your own code. |
| `upstream` | string (url) | **Required for the four providers whose endpoint is yours**, not the vendor's: `azure-openai`, `ollama`, `bedrock`, `openai-compatible`. Resolved and SSRF-checked at write time for `bedrock` and `openai-compatible` — a private, loopback or link-local address is a 400, because the request would carry your decrypted provider key there. |
| `upstream_secret_id` | string (uuid) | **Required.** ID of a secret in this tenant holding the provider API key. The gateway auto-creates an upstream route (`ai-gateway-{slug}`) wired to this secret. |
| `description` | string | Optional free-text description. |
| `default_model` | string | Optional. Defaults to that provider's pricebook default. **Required** for `openai-compatible`, which has no default because the endpoint is yours. |
| `budget_daily_usd` | number | Optional daily spend cap. |
| `budget_monthly_usd` | number | Optional monthly spend cap. |
| `streaming_enabled` | boolean | Optional. Defaults to `true`. |
The primary route is derived automatically from `provider` + `upstream_secret_id`; you do not pass `primary_route_id` at create time. Model policy (`model_allowlist`, `model_denylist`, `model_rewrite`), `tool_allowlist`, `output_schema` / `output_validation_action`, `fallback_route_ids`, and `firewall_policy_id` are configured **after** creation via `PATCH /admin/ai-gateway/{gatewayId}/agents/{agentId}` — they are not read by the create handler.
# Archive Agent
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-delete
DELETE /ai-gateway/agents/{id}
Take an agent out of service and disable the upstream route it orphans.
Sets the agent's status to `archived`. Its `/v1/ai/{slug}` route stops serving —
requests are refused with `503 agent_not_active`.
Archiving also **disables the upstream `routes` row it orphans**, scoped to the
data space you archived in, so archiving a sandbox agent does not disable the
Live route. Before 2026-08-30 the route stayed enabled and the upstream remained
reachable through an agent you had archived.
Existing phantom tokens for the agent are **not** revoked — they simply have
nothing to reach. Revoke them explicitly if the credential itself is the concern:
[Revoke a phantom token](/api-reference/ai-gateway/tokens-revoke).
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Get Agent
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-get
GET /ai-gateway/agents/{id}
Read one agent and its full configuration.
Returns the agent with every setting at its configured or default value —
routing, model policy, budgets, PII and firewall policy ids, cache mode, output
schema, residency.
An agent belonging to another tenant, or a KnoxCall system agent, returns
`404 not_found`: a probe cannot distinguish "not yours" from "does not exist".
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List Agents
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-list
GET /ai-gateway/gateways/{gatewayId}/agents
Every agent under one gateway.
Returns the gateway's agents, paginated. Each is served on the data plane at
`/v1/ai/{slug}` — the `agent_url` field is the base URL to point an SDK at.
KnoxCall-managed **system agents** (the Workflows system agent, for example) are
never returned here, and every other `/v1` operation on one answers
`404 not_found`.
For the full field list, see [the agent object](/api-reference/ai-gateway/agents#the-agent-object).
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update Agent
Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-update
PATCH /ai-gateway/agents/{id}
Partially update an agent — routing, model policy, budgets, guardrails.
Partial update. Omitted fields are left as they are. This is where most of an
agent's configuration is set: everything beyond the handful of fields create
accepts is applied here.
Two changes have effects worth knowing before you make them:
* **`status: "paused"`** refuses the agent's traffic with `503 agent_not_active`
on `/v1/ai`, and JSON-RPC `-32603` on `/v1/mcp`.
* **`guardrail_webhook_url`** is resolved and refused at write time through the
SSRF chokepoint, and may not carry credentials in the URL
(`https://user:pass@host/`) — a credential written into a URL is stored in
plaintext and leaks into every log line that echoes it.
Setting `firewall_policy_id` to a policy with `action: "block"` is what makes
the firewall refuse rather than warn — see [Prompt firewall](/ai-gateway/firewall).
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Control Plane (v1)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/control-plane-overview
Manage AI Gateways, agents, phantom tokens, and usage over the public /v1 Management API — authenticated with the SDK, an OAuth 2.1 token, or an API key.
# AI Gateway control plane
The AI Gateway control plane lets you create and manage gateways, agents, and phantom tokens — and read cost/usage — over the public **Management API** at `api.knoxcall.com/v1`. These are the same resources you configure in the dashboard, exposed as REST endpoints so you can provision them from code, CI, or the KnoxCall SDKs.
This is the **control plane** — it configures the gateway. To actually send AI traffic through an agent, call the **data plane** described in [Execute AI Request](/api-reference/ai-gateway/execute). The data plane lives on your tenant proxy subdomain and authenticates with a phantom token, not an API key.
The dashboard/admin surface documented on the [List Gateways](/api-reference/ai-gateway/gateways-list), [Create Agent](/api-reference/ai-gateway/agents-create), and [Mint Phantom Token](/api-reference/ai-gateway/tokens-mint) pages performs the same operations from a session-authenticated `/admin/ai-gateway` host. The `/v1/ai-gateway` endpoints below are the public equivalent and are what the KnoxCall SDKs call.
## Base URL
All control-plane endpoints are served under `/v1/ai-gateway`.
```text theme={"dark"}
https://api.knoxcall.com/v1/ai-gateway
```
Authenticate with a live-mode OAuth token (`kc_live_…`) or a legacy production key (`tk_live_…` / `AKE…`).
```text theme={"dark"}
https://sandbox.knoxcall.com/v1/ai-gateway
```
Authenticate with a test-mode OAuth token (`kc_test_…`) or a legacy test key (`tk_test_…`).
## Authentication
Authenticate exactly as you do for the rest of the Management API. The recommended path is the KnoxCall SDK — which mints, caches, and refreshes short-lived OAuth 2.1 access tokens for you — or a token you mint yourself; long-lived API keys remain supported as the legacy alternative. See the [Authentication guide](/api-reference/authentication) for the full picture.
| Method | Header | Notes |
| - | - | - |
| OAuth 2.1 (**use this**) | `Authorization: Bearer kc_live_…` | Short-lived token minted via `POST /oauth/token`; tenant is resolved from the token. The SDKs handle minting and refresh for you, and every example on these pages uses it. |
| API key (legacy) | `Authorization: Bearer tk_live_…` | Tenant is resolved from the key. Long-lived: prefer OAuth for anything new, and reach for a key only where you cannot run a mint step. |
The `x-api-key` header is also accepted on the Management API for backwards
compatibility, but it is not shown in these examples and new integrations
should not use it. `Authorization: Bearer` is the one header every credential
family works with.
Neither of these is the data-plane credential. `/v1/ai/{slug}` takes a
**phantom token** (`kp_…`) — a different credential family entirely. See
[Execute AI Request](/api-reference/ai-gateway/execute).
The curl examples on the AI Gateway reference pages authenticate with `$TOKEN`, a 1-hour OAuth access token minted once:
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
```
### Required scope
Every endpoint is gated on the `ai_gateway` scope. The action determines the capability your key must hold:
| Operation | HTTP | Capability on `ai_gateway` |
| - | - | - |
| List / read a gateway, agent, token, or usage | `GET` | `read` |
| Create / update / delete a gateway or agent | `POST` / `PATCH` / `DELETE` | `write` |
| Mint a phantom token | `POST …/tokens` | `mint` |
A key that lacks the required capability receives `403` with error type `forbidden`. No other scope grants access to these endpoints.
## Response envelope
Every response uses the standard Management API envelope.
**Single resource** — `{ data, meta }`:
```json theme={"dark"}
{
"data": {
"id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10",
"name": "Production"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
**List** — `{ data: [...], meta }` with pagination fields:
```json theme={"dark"}
{
"data": [ /* … */ ],
"meta": {
"total": 3,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
**Error** — `{ error }`:
```json theme={"dark"}
{
"error": {
"type": "not_found",
"message": "Gateway not found.",
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
## Pagination
List endpoints accept `page` and `per_page` query parameters and slice the full tenant result set.
| Parameter | Default | Max |
| - | - | - |
| `page` | `1` | — |
| `per_page` | `20` | `100` |
The `meta` object returns `total`, `page`, `per_page`, and `total_pages`.
## Sandbox and phantom-token environments
Gateways and agents are **not** environment-partitioned — the same gateway and agent objects are visible whether you authenticate with a live or a test credential. The live/test distinction lives in the **phantom token**: a token minted with a test-mode credential is a test-environment token (`kp_test_…`), and a token minted with a live credential is a live token (`kp_live_…`). There is no environment parameter on any request — the server derives it from the credential you used to mint.
Note the two prefixes are different credential families and are not interchangeable: `kc_` is an OAuth 2.1 access token for **this** Management API, `kp_` is a phantom token for the **data plane** (`/v1/ai/{slug}`). Presenting one where the other is expected is refused, not translated.
## Idempotency
Mutating requests (`POST`, `PATCH`, `DELETE`) honour the router-level `X-Idempotency-Key` header. Send the same key to retry a request safely without creating duplicates.
## Errors
In addition to the [shared authentication errors](/api-reference/authentication#authentication-errors), the control plane returns:
| Type | HTTP | When |
| - | - | - |
| `validation` | 400 | A required field (e.g. `name`, `slug`, or `dpop_jkt` when `dpop_required`) is missing. |
| `invalid_reference` | 400 | `primary_route_id`, `firewall_policy_id`, or `pii_redact_policy_id` does not reference a resource owned by your tenant. |
| `invalid_id` | 400 | A path or query identifier is not a valid UUID. |
| `forbidden` | 403 | The key lacks the required `ai_gateway` capability. |
| `not_found` | 404 | The gateway, agent, or token does not exist for this tenant. |
| `conflict` | 409 | A gateway or agent with that slug already exists for this tenant. |
| `invalid_slug` | 422 | The slug is not 2–64 lowercase alphanumerics/hyphens starting and ending with an alphanumeric. |
| `internal_error` | 500 | Unexpected server error — contact support with the `request_id`. |
## Endpoint index
| Method & path | Purpose |
| - | - |
| `GET /v1/ai-gateway/gateways` | [List gateways](/api-reference/ai-gateway/gateways#list-gateways) |
| `POST /v1/ai-gateway/gateways` | [Create a gateway](/api-reference/ai-gateway/gateways#create-a-gateway) |
| `GET /v1/ai-gateway/gateways/{id}` | [Get a gateway](/api-reference/ai-gateway/gateways#get-a-gateway) |
| `PATCH /v1/ai-gateway/gateways/{id}` | [Update a gateway](/api-reference/ai-gateway/gateways#update-a-gateway) |
| `DELETE /v1/ai-gateway/gateways/{id}` | [Archive a gateway](/api-reference/ai-gateway/gateways#archive-a-gateway) |
| `GET /v1/ai-gateway/gateways/{gatewayId}/agents` | [List agents](/api-reference/ai-gateway/agents#list-agents) |
| `POST /v1/ai-gateway/gateways/{gatewayId}/agents` | [Create an agent](/api-reference/ai-gateway/agents#create-an-agent) |
| `GET /v1/ai-gateway/agents/{agentId}` | [Get an agent](/api-reference/ai-gateway/agents#get-an-agent) |
| `PATCH /v1/ai-gateway/agents/{agentId}` | [Update an agent](/api-reference/ai-gateway/agents#update-an-agent) |
| `DELETE /v1/ai-gateway/agents/{agentId}` | [Archive an agent](/api-reference/ai-gateway/agents#archive-an-agent) |
| `GET /v1/ai-gateway/agents/{agentId}/tokens` | [List tokens](/api-reference/ai-gateway/tokens#list-tokens) |
| `POST /v1/ai-gateway/agents/{agentId}/tokens` | [Mint a token](/api-reference/ai-gateway/tokens#mint-a-token) |
| `DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId}` | [Revoke a token](/api-reference/ai-gateway/tokens#revoke-a-token) |
| `GET /v1/ai-gateway/usage` | [Usage & cost](/api-reference/ai-gateway/usage) |
# Execute AI Request
Source: https://docs.knoxcall.com/api-reference/ai-gateway/execute
POST /v1/ai/{agentSlug}/{upstreamPath}
Proxy an AI request through the gateway with phantom-token auth, streaming PII redaction, prompt firewall, budget enforcement, and shape translation.
# AI Gateway
The AI Gateway is KnoxCall's AI-native proxy layer. It sits between your applications and upstream AI providers (Anthropic and OpenAI are the configurable providers; Bedrock is supported only as a shape-translation target), providing:
* **Phantom tokens** — short-lived DPoP-bound capability tokens; your application never holds a raw provider API key.
* **Streaming PII redaction** — Aho-Corasick + Presidio detector stack with a configurable hold-back buffer.
* **Prompt injection firewall** — built-in heuristic patterns + tenant-supplied regex/keyword rules; optional canary token injection detects system-prompt extraction.
* **Budget enforcement** — per-agent daily/monthly USD caps with configurable overage actions.
* **Shape translation** — automatic Anthropic ↔ OpenAI request/response translation when routes target a different provider format.
* **Compliance packs** — HIPAA Safe Harbor, GDPR, PCI-DSS, and SOC 2 packs install recognizers and audit-log alert rules in one operation.
***
## Authentication
All AI Gateway proxy endpoints authenticate via **phantom tokens** — capability tokens minted for a specific agent. Send the token via any of the three accepted header schemes:
```http theme={"dark"}
Authorization: Bearer kp_live_a_...
```
```http theme={"dark"}
x-api-key: kp_live_a_...
```
```http theme={"dark"}
x-knox-ai-key: kp_live_a_...
```
For DPoP-bound tokens, you must additionally include a valid `DPoP` header on every request.
Gateway and agent CRUD, token minting, firewall policies, MCP servers and usage are **control-plane** operations and are served on two different surfaces:
| Surface | Base | Credential |
| - | - | - |
| Public Management API (**use this**) | `https://api.knoxcall.com/v1/ai-gateway` | An OAuth 2.1 access token (`Authorization: Bearer kc_live_…`), or a legacy `tk_…` / `AKE…` API key. Covered by all five SDKs and the [control-plane overview](/api-reference/ai-gateway/control-plane-overview). |
| Dashboard admin API | `/admin/ai-gateway` on the admin host | A session-authenticated request with the `X-Tenant-ID` header and an owner/admin role. This is what the dashboard itself calls; a few operations exist only here (they are called out where that is the case). |
An earlier revision of this page said the control plane requires a session JWT and **not** an API key. That has been false since `/v1/ai-gateway/*` shipped: the `/v1` surface authenticates with the same credentials as the rest of the Management API.
***
## Execute AI request
```http theme={"dark"}
POST https://{slug}.knoxcall.com/v1/ai/{agent-slug}/{...path}
```
The execute (data-plane) endpoint is served on your tenant's proxy subdomain — `https://{slug}.knoxcall.com` (sandbox: `https://sandbox-{slug}.knoxcall.com`), **not** `api.knoxcall.com`. Use the `agent_url` returned when the agent was created as the base URL.
Forwards the request to the agent's configured primary route. The request body must be a valid JSON body for the upstream provider (Anthropic Messages API, OpenAI Chat Completions API, etc.).
If the agent has `streaming_enabled` and the request asks the provider to stream — `"stream": true` in the body, or Gemini's `:streamGenerateContent` endpoint — the response is streamed as SSE. An `Accept: text/event-stream` header on its own does not stream: it asks nothing of the provider, so the response is served buffered (and fully redacted). Streaming is available on the endpoints whose event stream KnoxCall redacts mid-stream — Anthropic `/v1/messages`, OpenAI-compatible `/chat/completions`, Gemini `:streamGenerateContent` — and is refused with `400 streaming_redaction_unsupported` elsewhere (for example the OpenAI Responses API); send those requests without `stream: true`.
### Request headers
| Header | Description |
| - | - |
| `Authorization: Bearer kp_live_a_...` | Phantom token for this agent. May alternatively be sent via the `x-api-key` or `x-knox-ai-key` header. |
| `DPoP` | DPoP proof JWT (required when the token has `dpop_required: true`). |
| `X-KC-User` | Optional. The person's workspace-directory key, for per-user cost attribution: their SCIM `externalId` (or `userName` when the IdP sends none), otherwise a member's KnoxCall email. Resolves only while that person is a member; otherwise the response carries `X-Knox-AI-Attribution: user_unregistered`. |
| `X-KC-Conversation-Id` | Optional. Conversation identifier for PII token-map scoping across turns. |
### Response headers
| Header | Description |
| - | - |
| `X-Request-Id` | UUID identifying this proxy call (appears in audit logs). |
| `X-Knox-AI-Budget-Pct` | Current budget utilization percentage (when budget is configured). |
| `X-Knox-AI-Budget-Warning` | Present when utilization exceeds the warn threshold. |
| `X-Knox-AI-Tools-Stripped` | Comma-separated names of tools removed by the agent's tool allowlist. |
| `X-Knox-AI-Output-Warning` | Present when the response fails output schema validation but `output_validation_action` is `warn`. |
| `X-Knox-AI-Retry` | Set to `output_schema` when the response was automatically retried due to schema violation. |
### Firewall responses
When the prompt injection firewall blocks a request, `HTTP 400` in the data
plane's [one error envelope](/ai-gateway/errors):
```json theme={"dark"}
{
"error": "firewall_block",
"error_description": "Request blocked by firewall policy: ignore_previous_instructions, dan_jailbreak",
"code": "firewall_block"
}
```
`error` and `code` always carry the same identifier — branch on either. Every
refusal this endpoint can return, and whether to retry it, is on the
[errors page](/ai-gateway/errors).
The firewall **always runs** — the built-in heuristics cannot be disabled and they scan every request. What a policy decides is the **outcome**. With no firewall policy attached to the agent, the outcome is capped at `warn`: a match is recorded and the request still reaches the upstream. `block` requires a firewall policy attached to the agent with `action: "block"`. Create one via [`POST /v1/ai-gateway/firewall-policies`](/api-reference/ai-gateway/firewall-policies-create) and set the agent's `firewall_policy_id`.
***
## Shape translation
When the caller's provider format differs from the route's target provider format, the AI Gateway translates automatically:
| From | To | What changes |
| - | - | - |
| Anthropic | OpenAI | `system` field → system message; `input_schema` → `parameters`; tool choice shape |
| OpenAI | Anthropic | Leading system message → `system` field; `parameters` → `input_schema` |
| Any | Bedrock | Strips `model` from body; adds `anthropic_version: "bedrock-2023-05-31"` |
Format is detected from the route's `target_base_url` (e.g. `api.anthropic.com` → Anthropic format). Response shapes are translated back to match the caller's expected format on the buffered path.
***
## Canary token injection
When a firewall policy has `canary_enabled: true`, the gateway injects a `kc_canary_<16hex>` token into every system prompt before forwarding upstream. If the model echoes the token verbatim in its response (a sign of system-prompt extraction via prompt injection), the gateway:
1. Emits an `ai_gateway.canary_leak` audit log entry at `critical` severity.
2. Records the **policy's own action** as `firewall_outcome` on the usage record —
`block` when the attached policy's action is `block`, otherwise `warn`. (Before
AIGW-171 this was hard-coded to `warn`, so a tenant who had configured `block`
read their own firewall report as saying the control had held.)
3. On a `block` policy, refuses the response rather than serving it. A canary echo
is not a heuristic guess about intent: the token is a value KnoxCall generated,
put in the system prompt, and never gave the caller. **Buffered**, that is
`400 canary_leak`. **Streamed**, the `200` has already gone out, so the stream
stops at the frame carrying the leak and ends with an `event: error` frame whose
`code` is `canary_leak` — nothing after the leak is delivered, and anything sent
before it contained no canary. The refusal reports a `matched_count`, never the
token itself.
4. Fires any compliance-pack alert rules listening on `audit_action = 'ai_gateway.canary_leak'`.
Either way the marker itself is spliced out of the body the caller receives
(`[REDACTED:CANARY]`), on the buffered path and mid-stream — **except in the
`monitor` streaming mode**, which passes the response through unredacted by
design and only inspects it once it has finished.
***
## Compliance packs
Install compliance packs via the [Packs API](/api-reference/packs) or from the Admin UI. Each pack installs:
1. **PII recognizers** — entity-type patterns for the detector stack.
2. **Alert rules** — `ai_gateway_event` alerts that fire when specific audit actions appear.
Currently available packs: `hipaa-safe-harbor`, `gdpr`, `pci-dss`, `soc2`.
***
## OpenTelemetry export
Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable per-request span export with GenAI semantic conventions:
| Attribute | Value |
| - | - |
| `gen_ai.system` | Provider (e.g. `anthropic`) |
| `gen_ai.request.model` | Requested model |
| `gen_ai.usage.input_tokens` | Input token count |
| `gen_ai.usage.output_tokens` | Output token count |
| `knoxcall.ai_gateway.firewall_outcome` | `pass` / `warn` / `block` / `tag` |
| `knoxcall.ai_gateway.pii_tokens_swapped` | Count of PII redactions |
| `knoxcall.ai_gateway.cost_usd` | Estimated cost in USD |
# Create a Firewall Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-create
POST /ai-gateway/firewall-policies
Create a prompt-injection policy — and make `block` reachable for an agent.
Creates a policy. `action` is what makes this endpoint matter:
| `action` | What a match does |
| - | - |
| `block` | The request is refused with `400 firewall_block` and never reaches the provider. |
| `warn` | The request proceeds; the match is recorded. |
| `tag` | The request proceeds; the match is recorded as `tag`. |
**With no policy attached to an agent, the outcome is `warn` and `block` is
unreachable.** Creating a policy with `action: "block"` and setting the agent's
`firewall_policy_id` is what turns detection into refusal.
Every regex rule is compiled through a safe-regex validator at write time, so a
pattern that could backtrack catastrophically is refused here rather than
discovered on the hot path. Dry-run a rule set first with
[Test firewall rules](/api-reference/ai-gateway/firewall-policies-test) — the
tester compiles through the same validator, so a pattern it accepts can actually
run.
Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Delete a Firewall Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-delete
DELETE /ai-gateway/firewall-policies/{id}
Remove a firewall policy.
Deletes the policy.
Clear `firewall_policy_id` on every agent that references this policy **first**.
An agent pointing at a policy that no longer exists is refused with
`503 firewall_policy_missing` on every request — deliberately, because serving
it would mean running with the firewall the operator configured silently absent.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Get a Firewall Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-get
GET /ai-gateway/firewall-policies/{id}
Read one firewall policy and its rules.
Returns one policy owned by your tenant, with its rules and its `action`.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List Firewall Policies
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-list
GET /ai-gateway/firewall-policies
Every prompt-injection firewall policy for your tenant.
Firewall policies are **tenant-scoped**, not gateway-scoped: one policy can be
attached to agents across every gateway you own.
Attach one by setting an agent's `firewall_policy_id`. Until you do, the built-in
heuristics still run on that agent's traffic but the outcome is capped at
`warn` — see [Prompt firewall](/ai-gateway/firewall).
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Test Firewall Rules
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-test
POST /ai-gateway/firewall-policies/test
Dry-run a rule set against sample text before you attach it to anything.
Runs a candidate `heuristics` array against a sample `text` and returns what
matched. Nothing is stored and no policy is created.
Two properties make the result trustworthy:
* **It compiles through the same validator the writer uses.** A rule the tester
refuses is a rule `POST /v1/ai-gateway/firewall-policies` would also refuse, so
the tester can never green-light a pattern that could not run.
* **It reports `skipped` rules explicitly.** A rule that fails to compile is
named rather than silently ignored. On a live `block` policy a skipped rule is
a fail-closed condition — the gateway refuses the request with
`503 firewall_policy_degraded` rather than serve it under a policy it knows is
only partly applied.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update a Firewall Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-update
PATCH /ai-gateway/firewall-policies/{id}
Change a policy's rules or its action — including switching it to `block`.
Partial update. Omitted fields are left as they are.
Changing `action` to `block` takes effect on every agent that references this
policy, on their next request. Dry-run the rule set with
[Test firewall rules](/api-reference/ai-gateway/firewall-policies-test) first —
under `block`, a rule that will not compile stops being a warning and becomes a
`503` on live traffic.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List Gateway Tokens
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateway-tokens-list
GET /ai-gateway/gateways/{gatewayId}/tokens
Every phantom token under one gateway — including agentless tokens that no agent endpoint can reach.
Lists the phantom tokens issued under this gateway: the tokens of every agent it
owns, **and** any token minted without an agent at all.
That second category is why this endpoint exists. A token minted through the
[OIDC exchange](/ai-gateway/tokens-and-dpop#oidc-workload-federation-ci-without-static-secrets)
with an RFC 8707 `resource` naming an MCP server has `agent_id: null`, so it is
invisible to `GET /v1/ai-gateway/agents/{agentId}/tokens` — the only other place
tokens are listed. Without this endpoint an agentless token could be neither
inventoried nor found.
Never returns plaintext; `prefix` is the first 12 characters, safe to display.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Revoke a Gateway Token
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateway-tokens-revoke
DELETE /ai-gateway/gateways/{gatewayId}/tokens/{tokenId}
Revoke any token under a gateway, including an agentless one no agent endpoint can reach.
Revokes a token that belongs to this gateway — either to one of its agents, or to
no agent at all. Revocation takes effect immediately; in-flight requests that
have already passed authentication complete.
This is the **only** way to revoke an agentless token. Pair it with
[List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list) when you find
a credential you cannot account for.
Returns `404 not_found` if the token does not exist under this gateway, or is
already revoked. There is no `POST` counterpart: `/v1` does not mint
gateway-level tokens.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Gateways (v1)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways
List, create, read, update, and archive AI Gateways over the public /v1 Management API.
# Gateways
A **gateway** is the top-level container for AI agents. It carries an optional daily/monthly USD budget that applies across its agents. These endpoints are the public `/v1` control-plane equivalent of the dashboard gateway pages — see the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the response envelope, and error types. The examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there.
Base path: `https://api.knoxcall.com/v1/ai-gateway/gateways`
## The gateway object
| Field | Type | Description |
| - | - | - |
| `id` | string (uuid) | Gateway identifier. |
| `tenant_id` | string (uuid) | Owning tenant. |
| `name` | string | Display name. |
| `slug` | string | URL-safe handle, unique per tenant. Set at creation and not editable. |
| `description` | string \| null | Free-text description. |
| `budget_daily_usd` | string \| null | Daily spend cap in USD (numeric, returned as a string). |
| `budget_monthly_usd` | string \| null | Monthly spend cap in USD. |
| `status` | string | One of `active`, `paused`, `archived`. |
| `paused_reason` | string \| null | Set when the gateway is paused. |
| `created_at` | string (ISO 8601) | Creation timestamp. |
| `updated_at` | string (ISO 8601) | Last-update timestamp. |
| `created_by` | string (uuid) \| null | API key / actor that created it. |
## List gateways
```http theme={"dark"}
GET /v1/ai-gateway/gateways
```
Returns your tenant's gateways, paginated (`page`, `per_page`). Requires the `read` capability on `ai_gateway`.
```bash cURL theme={"dark"}
curl "https://api.knoxcall.com/v1/ai-gateway/gateways?page=1&per_page=20" \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
resp = requests.get(
"https://api.knoxcall.com/v1/ai-gateway/gateways",
headers={"Authorization": f"Bearer {TOKEN}"},
params={"page": 1, "per_page": 20},
)
gateways = resp.json()["data"]
```
```javascript Node.js theme={"dark"}
const resp = await fetch(
"https://api.knoxcall.com/v1/ai-gateway/gateways?page=1&per_page=20",
{ headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }
);
const { data: gateways } = await resp.json();
```
**Response**
```json theme={"dark"}
{
"data": [
{
"id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10",
"tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"name": "Production",
"slug": "production",
"description": null,
"budget_daily_usd": "50.00",
"budget_monthly_usd": "1000.00",
"status": "active",
"paused_reason": null,
"created_at": "2026-05-01T00:00:00.000Z",
"updated_at": "2026-05-01T00:00:00.000Z",
"created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
}
],
"meta": {
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
## Create a gateway
```http theme={"dark"}
POST /v1/ai-gateway/gateways
```
Requires the `write` capability on `ai_gateway`.
**Request body**
| Field | Type | Description |
| - | - | - |
| `name` | string | **Required.** Display name. |
| `slug` | string | **Required.** 2–64 lowercase alphanumerics/hyphens; must start and end with an alphanumeric. Unique per tenant. |
| `description` | string | Optional. |
| `budget_daily_usd` | number | Optional daily spend cap. |
| `budget_monthly_usd` | number | Optional monthly spend cap. |
```bash cURL theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production",
"slug": "production",
"description": "Production AI egress",
"budget_daily_usd": 50.00,
"budget_monthly_usd": 1000.00
}'
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
resp = requests.post(
"https://api.knoxcall.com/v1/ai-gateway/gateways",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"name": "Production",
"slug": "production",
"description": "Production AI egress",
"budget_daily_usd": 50.00,
"budget_monthly_usd": 1000.00,
},
)
gateway = resp.json()["data"]
```
**Response** — the created gateway wrapped in `{ data, meta }`:
```json theme={"dark"}
{
"data": {
"id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10",
"tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"name": "Production",
"slug": "production",
"description": "Production AI egress",
"budget_daily_usd": "50.00",
"budget_monthly_usd": "1000.00",
"status": "active",
"paused_reason": null,
"created_at": "2026-07-06T12:00:00.000Z",
"updated_at": "2026-07-06T12:00:00.000Z",
"created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
Missing `name` or `slug` returns `400 validation`; an invalid slug returns `422 invalid_slug`; a duplicate slug returns `409 conflict`.
## Get a gateway
```http theme={"dark"}
GET /v1/ai-gateway/gateways/{id}
```
Returns a single gateway. Requires the `read` capability. Returns `404 not_found` if the gateway does not belong to your tenant.
```bash theme={"dark"}
curl https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \
-H "Authorization: Bearer $TOKEN"
```
## Update a gateway
```http theme={"dark"}
PATCH /v1/ai-gateway/gateways/{id}
```
Partial update — all fields optional. Requires the `write` capability. The `slug` cannot be changed.
**Request body**
| Field | Type | Description |
| - | - | - |
| `name` | string | New display name. |
| `description` | string | New description. |
| `budget_daily_usd` | number | New daily cap. |
| `budget_monthly_usd` | number | New monthly cap. |
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "budget_daily_usd": 75.00 }'
```
The response is the updated gateway wrapped in `{ data, meta }`.
## Archive a gateway
```http theme={"dark"}
DELETE /v1/ai-gateway/gateways/{id}
```
Soft-deletes (archives) the gateway and returns its id and new status. Requires the `write` capability.
```bash theme={"dark"}
curl -X DELETE https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \
-H "Authorization: Bearer $TOKEN"
```
**Response**
```json theme={"dark"}
{
"data": {
"id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10",
"status": "archived"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
Archiving a gateway does not change the status of its agents. The data plane gates each request on the **agent's** status, so an agent that is still `active` keeps serving traffic. To stop traffic, [archive the agents directly](/api-reference/ai-gateway/agents#archive-an-agent).
# Create Gateway
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-create
POST /ai-gateway/gateways
Create an AI Gateway for your tenant.
Creates a gateway. `slug` must be 2–64 lowercase alphanumerics or hyphens,
starting and ending with an alphanumeric; anything else is `422 invalid_slug`,
and a slug already in use is `409 conflict`.
Creating a gateway also seeds a **Default** prompt-firewall policy for the tenant
if one does not exist yet, and
[Create an agent](/api-reference/ai-gateway/agents#create-an-agent) attaches it
when the caller names no `firewall_policy_id`.
That default policy's `action` is **`warn`**, deliberately: a policy silently
inherited by every future agent must not be able to start refusing their traffic.
So blocking is opt-in either way — change the Default policy's action, or attach
one of your own with `action: "block"`. See
[Prompt firewall](/ai-gateway/firewall).
**The dashboard twin.** The same operation exists on the admin host as
`POST /admin/ai-gateway`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# Archive Gateway
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-delete
DELETE /ai-gateway/gateways/{id}
Archive an AI Gateway and disable the upstream routes it orphans.
Sets the gateway's status to `archived` and returns the updated gateway.
**Archiving a gateway does not archive its agents.** Each agent keeps its own
status. Since 2026-08-27 an agent under an archived gateway is refused with
`503 gateway_not_active`, so traffic does stop — but the agent rows are still
`active`, and un-archiving the gateway resumes them. If you mean to retire the
agents, archive them individually.
Archiving also **disables the upstream `routes` rows it orphans**, scoped to the
data space you archived in — so archiving a sandbox agent does not disable the
Live route. Before 2026-08-30 the routes stayed enabled, which left the upstream
reachable through a gateway you had archived.
**The dashboard twin.** The same operation exists on the admin host as
`DELETE /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# Get Gateway
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-get
GET /ai-gateway/gateways/{id}
Retrieve a single AI Gateway.
Returns one gateway owned by your tenant. A gateway belonging to another tenant
returns `404 not_found`, so a probe cannot distinguish "not yours" from "does not
exist". A malformed id is `400 invalid_id`.
To list its agents, use
[List agents](/api-reference/ai-gateway/agents#list-agents); its MCP servers,
[List MCP servers](/api-reference/ai-gateway/mcp-servers-list); its tokens —
including agentless ones —
[List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list).
**The dashboard twin.** The same operation exists on the admin host as
`GET /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# List Gateways
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-list
GET /ai-gateway/gateways
Return every AI Gateway for your tenant, paginated.
Returns your tenant's gateways, newest first, with `agent_count` on each.
Gateways are **not** environment-partitioned: the same objects come back whether
you authenticate with a live or a test credential. The live/test distinction
lives in the phantom token — see
[Sandbox and phantom-token environments](/api-reference/ai-gateway/control-plane-overview#sandbox-and-phantom-token-environments).
**The dashboard twin.** The same operation exists on the admin host as
`GET /admin/ai-gateway`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# Update Gateway
Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-update
PATCH /ai-gateway/gateways/{id}
Partially update an AI Gateway — every field is optional.
Partial update. Omitted fields are left as they are.
Setting `status` to `paused` stops the gateway's traffic: an agent under a paused
gateway is refused with `503 gateway_not_active` on `/v1/ai`, and with JSON-RPC
`-32603` on `/v1/mcp`. That has been true on both planes since 2026-08-27 — the
MCP plane previously kept serving.
**The dashboard twin.** The same operation exists on the admin host as
`PATCH /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# List MCP Connections
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-list
GET /ai-gateway/mcp-servers/{id}/grants
Who has connected their own upstream account to a delegated-OAuth MCP server.
A **connection** is one person's consent to let KnoxCall act as them against this
server's upstream (`auth.mode: "delegated"`). KnoxCall holds their refresh token
envelope-encrypted under your tenant key and mints a short-lived access token per
call.
**No response here carries a token** — not redacted, not truncated, absent.
`has_refresh_token` is the only thing said about them; `false` means the
connection cannot outlive its current access token and the person should
reconnect.
**Creating a connection is not an API operation.** Consent has to be given by the
person whose credential it is, so the flow starts from a signed-in KnoxCall
session on the MCP server's page in the console. An API key is not a person: an
endpoint that started a flow "for alice\@acme" would let whoever holds the key
finish it with their *own* upstream account, and the connection would hold the
key holder's credential under Alice's name.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Revoke an MCP Connection
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-revoke
DELETE /ai-gateway/mcp-servers/{id}/grants/{grantId}
Destroy one person's stored upstream tokens for a delegated-OAuth MCP server.
The stored refresh and access tokens are **destroyed**, not flagged: the table
refuses a revoked row that still holds ciphertext. That person's next tool call
on this server is refused until they connect again.
This does **not** revoke KnoxCall's authorization at the upstream. If you need it
gone there too, remove the app in the upstream's own account settings — only the
account holder, or that provider's admin, can do that.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope and error types.
# Revoke All MCP Connections
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-revoke-all
DELETE /ai-gateway/mcp-servers/{id}/grants
Offboarding in one call: revoke every delegated-OAuth connection on an MCP server.
Revokes every connection on this MCP server and returns how many. Each one's
stored tokens are destroyed rather than flagged.
Archiving the server, or changing its `auth` configuration, does the same thing
automatically — the stored tokens were issued for one OAuth app, one set of
scopes and one upstream, so keeping them across a change would mean dialling with
credentials minted under settings nobody can see any more.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope and error types.
# Create an MCP Server
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-create
POST /ai-gateway/gateways/{gatewayId}/mcp-servers
Register an upstream MCP server under a gateway and choose how it authenticates upstream.
Registers an upstream MCP server. Four things are worth knowing before you call
it, each of which is a refusal rather than a surprise later:
* **`allowed_tools` defaults to empty, and empty means *no tools*.** A server
created without it advertises nothing on `tools/list` and refuses every
`tools/call`. The response says so in `meta.note` when that happens. This is
deliberate — a governed gateway does not open by default.
* **`server_type: "collection"` is refused** with `422 unsupported_server_type`.
The forwarder does not serve that type, so the row would fail every call. Only
`upstream` is served.
* **`transport` must be `streamable_http`.** Anything else is `422
unsupported_transport`; the gateway does not speak the legacy SSE transport.
* **`upstream_url` goes through the SSRF chokepoint** at write time and again at
connect time. A URL that resolves to a private or link-local address is
refused, not dialled.
`auth` selects how the server reaches its upstream: `{"mode": "secret"}` (the
default) resolves one credential from your secret store per connection, and
`{"mode": "passthrough"}` makes each caller supply their own — see
[how the server authenticates upstream](/ai-gateway/mcp-gateway#how-the-server-authenticates-upstream).
A literal credential in `auth.headers` is refused at write time; reference a
Secret with `{{secret_id:}}`.
MCP servers are a plan entitlement — creating one beyond your plan's allowance is
refused.
Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Archive an MCP Server
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-delete
DELETE /ai-gateway/mcp-servers/{id}
Take an MCP server out of service.
Archives the server. Its `/v1/mcp/{slug}` route stops resolving, and calls to it
are refused rather than served from a stale upstream.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Get an MCP Server
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-get
GET /ai-gateway/mcp-servers/{id}
Read one MCP server, without its upstream credential.
Returns one MCP server owned by your tenant. The stored upstream credential is
never included in the response — only the auth *mode*.
Returns `404 not_found` if the server belongs to another tenant, so a probe
cannot distinguish "not yours" from "does not exist".
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List MCP Servers
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-list
GET /ai-gateway/gateways/{gatewayId}/mcp-servers
The MCP servers registered under a gateway.
Returns the gateway's MCP servers, paginated. Each is served on the data plane at
`/v1/mcp/{slug}` — see [MCP gateway](/ai-gateway/mcp-gateway) for what the gateway
does to a `tools/call` on the way through.
A server's stored upstream credential is never returned; `auth` comes back with
its mode and nothing sensitive.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update an MCP Server
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-update
PATCH /ai-gateway/mcp-servers/{id}
Change a server's upstream, tool allowlist, PII inspection or auth mode.
Partial update. Omitted fields are left as they are.
The same write-time refusals apply as on
[create](/api-reference/ai-gateway/mcp-servers-create): a new `upstream_url` is
re-validated through the SSRF chokepoint, a literal credential in `auth.headers`
is refused, and userinfo in the URL (`https://user:pass@host/`) is rejected — a
credential written into a URL lands in plaintext in every log line and error
message that echoes it.
Setting `allowed_tools` to `[]` takes every tool off the server. That is a valid
thing to want and it is not a soft state: the server will advertise nothing.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Delete an MCP Tool
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-delete
DELETE /ai-gateway/mcp-servers/{id}/tools/{toolId}
Remove a recorded tool definition from an MCP server.
Removes the tool row.
If the tool name is still in the server's `allowed_tools`, the allowlist entry
survives this call — remove it there too, or the name stays permitted the moment
the upstream advertises it again.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List MCP Tools
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-list
GET /ai-gateway/mcp-servers/{id}/tools
The tool definitions recorded for one MCP server.
Returns the tool rows KnoxCall holds for this server.
These are **not** the same thing as what a client sees from `tools/list` on the
data plane. That response is the upstream's advertised tools intersected with the
server's `allowed_tools` **and** the presenting token's own `tools` scope — so a
tool listed here can still be invisible, and unreachable, to a particular
credential.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update an MCP Tool
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-update
PATCH /ai-gateway/mcp-servers/{id}/tools/{toolId}
Change one recorded tool definition.
Partial update of a single tool row. Omitted fields are left as they are.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Add or Replace an MCP Tool
Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-upsert
POST /ai-gateway/mcp-servers/{id}/tools
Record a tool definition on an MCP server.
Creates the tool row, or replaces it if one with the same name already exists.
Recording a tool here does not by itself expose it. A tool is reachable only when
it is also in the server's `allowed_tools` — the allowlist is the control, and
this endpoint is the catalogue.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Workload Token Exchange
Source: https://docs.knoxcall.com/api-reference/ai-gateway/oauth-token
Exchange a CI system's OIDC id_token for a short-lived phantom token — RFC 8693 token exchange on the AI Gateway data plane, with no stored secret in the pipeline.
### Workload token exchange
```http theme={"dark"}
POST https://{tenant-slug}.knoxcall.com/v1/oauth/token
```
Exchange a workload's OIDC **id\_token** — a GitHub Actions token, a GCP or AWS
workload identity token, an Azure managed-identity token — for a short-lived
KnoxCall phantom token, using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)
token exchange. Nothing long-lived is stored in the pipeline.
**This is not the OAuth 2.1 token endpoint.** `POST https://api.knoxcall.com/oauth/token`
mints management-API access tokens for `/v1/*`. This endpoint lives on your
**tenant's data-plane subdomain** under `/v1/oauth/token`, and mints **phantom
tokens** for `/v1/ai/` and `/v1/mcp/`. The two are separate endpoints
with separate credentials.
The endpoint is **unauthenticated by design** — the subject token *is* the
credential, and verifying it is what the endpoint is being asked to do.
**Prerequisite: an OIDC binding**
An exchange only succeeds against a binding you have configured (the agent's
Federation section, or the admin API). A binding pins:
* the trusted **issuer**, whose JWKS is fetched over HTTPS from its published
discovery document;
* the required **attribute conditions** — for example a specific repository, ref
or environment;
* the **capability scope** and TTL of the token that gets minted.
A binding with **zero attribute conditions is refused**. A binding that matched any
token from an issuer would hand a KnoxCall credential to every repository on
GitHub.
**Request**
`application/x-www-form-urlencoded` (JSON is also accepted).
```http theme={"dark"}
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
```
| Field | Required | Notes |
| - | - | - |
| `grant_type` | yes | Must be `urn:ietf:params:oauth:grant-type:token-exchange`. |
| `subject_token` | yes | The workload's OIDC id\_token (a compact JWS). |
| `subject_token_type` | yes | Must be `urn:ietf:params:oauth:token-type:id_token`. |
| `audience` | no | Defaults to KnoxCall's own audience; any other value is refused. |
| `resource` | no | [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) resource indicator — an MCP resource URI on this host, e.g. `https://acme.knoxcall.com/v1/mcp/weather`. A single string only; an array or object is refused rather than silently dropped. |
**Response**
```json theme={"dark"}
{
"access_token": "kp_live_a_...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 900,
"scope": "{\"models\":[\"claude-sonnet-*\"]}"
}
```
`expires_in` is the binding's `access_token_ttl_seconds`, default **900**. `scope`
is a JSON serialisation for visibility only — the enforceable copy is bound into
the token's capability HMAC and cannot be edited after minting.
**What `resource` changes**
| Called with | Token kind | Where it works |
| - | - | - |
| no `resource` | `agent` | `/v1/ai/` |
| a `resource` | `tool` | **only** the named `/v1/mcp/` |
A resource-bound token is confined to exactly that MCP server: the canonical
resource URI is bound *into* the capability scope, so the HMAC covers it. The MCP
plane requires a `tool`-kind token, and the AI plane refuses a token with no agent
— so the kind narrows to match what you asked for, and never widens.
**Single use**
A given `subject_token` can be exchanged **once**. A replay returns
`invalid_grant`, which is what stops a leaked CI log from being worth anything.
**Errors**
RFC 6749-shaped: `{ "error": "...", "error_description": "..." }`.
| `error` | Cause |
| - | - |
| `unsupported_grant_type` | `grant_type` is not the token-exchange URN. |
| `invalid_request` | Missing or malformed `subject_token`, wrong `subject_token_type`, or a subject token that is not a JWS triple. |
| `invalid_target` | Unsupported `audience`, or a `resource` that is not a valid MCP resource URI on this host. |
| `invalid_grant` | Signature verification failed, the token is expired or not yet valid, no enabled binding matches its issuer and attributes, or the subject token has already been exchanged. |
| `server_error` | Issuer JWKS unreachable, or the tenant master key is unavailable. |
**Rate limits**
30 requests/minute per (client IP, issuer, subject) and 60/minute per client IP.
The client IP is deliberately part of the subject bucket: `iss` and `sub` come from
an unverified JWT and are public for the primary use case, so keying on the claims
alone would let anyone who knows your repository name lock out your real CI runs.
See [Limits](/ai-gateway/limits#request-rate).
**Example — GitHub Actions**
```yaml theme={"dark"}
permissions:
id-token: write
steps:
- name: Exchange for a KnoxCall phantom token
run: |
OIDC=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=knoxcall" | jq -r .value)
KC=$(curl -s -X POST https://acme.knoxcall.com/v1/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$OIDC" \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
| jq -r .access_token)
curl https://acme.knoxcall.com/v1/ai/support-bot/v1/messages \
-H "Authorization: Bearer $KC" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
```
See [Tokens & DPoP](/ai-gateway/tokens-and-dpop#oidc-workload-federation-ci-without-static-secrets)
for the conceptual overview.
# Create a PII Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-create
POST /ai-gateway/pii-policies
Create a PII redaction policy and choose which recognizers it runs.
Creates a policy. Until this endpoint shipped, PII policies could only be created
in the dashboard — so a `pii_redact_policy_id` was obtainable only by a UI detour,
and a compliance-shaped setup could not be scripted end to end from an API key.
It can now.
Attach the policy by setting an agent's `pii_redact_policy_id` with
[Update agent](/api-reference/ai-gateway/agents-update).
Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Delete a PII Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-delete
DELETE /ai-gateway/pii-policies/{id}
Remove a PII redaction policy.
Deletes the policy.
Clear `pii_redact_policy_id` on every agent that references this policy
**first**. An agent pointing at a policy that no longer exists is refused with
`503`, code `pii_policy_unavailable`, on every request — deliberately: serving it would mean
forwarding prompts with the redaction the operator configured silently absent.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Get a PII Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-get
GET /ai-gateway/pii-policies/{id}
Read one PII redaction policy.
Returns one policy owned by your tenant. A policy belonging to another tenant
returns `404 not_found`.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List PII Policies
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-list
GET /ai-gateway/pii-policies
Every PII redaction policy for your tenant.
PII policies are **tenant-scoped**: one policy can be attached to agents across
every gateway you own. Attach one by setting an agent's `pii_redact_policy_id`.
See [PII redaction](/ai-gateway/pii-redaction) for what a policy does to a
request and a response.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update a PII Policy
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-update
PATCH /ai-gateway/pii-policies/{id}
Change a policy's recognizers or behaviour.
Partial update. Omitted fields are left as they are.
A change takes effect on the data plane within the recognizer cache's short TTL —
the cache is invalidated on write, so it is effectively immediate.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Create a PII Recognizer
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-create
POST /ai-gateway/pii-recognizers
Add a custom PII pattern for your tenant.
Creates a recognizer. The pattern is compiled through a safe-regex validator at
write time, so a pattern that could backtrack catastrophically is refused here
rather than discovered on the hot path.
Dry-run it first with
[Test a recognizer pattern](/api-reference/ai-gateway/pii-recognizers-test) — the
tester compiles through the same validator, so a pattern it accepts can actually
run.
A recognizer that fails to compile at request time is a **fail-closed** condition
on the data plane: the request is refused with `503 pii_policy_degraded` rather
than forwarded with part of the policy silently not running.
Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Delete a PII Recognizer
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-delete
DELETE /ai-gateway/pii-recognizers/{id}
Remove a custom recognizer.
Deletes the recognizer. Any policy that referenced it stops running that pattern
on the data plane within the recognizer cache's short TTL — the cache is
invalidated on write, so it is effectively immediate.
Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Get a PII Recognizer
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-get
GET /ai-gateway/pii-recognizers/{id}
Read one custom recognizer.
Returns one recognizer owned by your tenant. One belonging to another tenant
returns `404 not_found`.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# List PII Recognizers
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-list
GET /ai-gateway/pii-recognizers
Your tenant's custom PII recognizers.
Returns your custom recognizers — the tenant-authored patterns that run alongside
the built-in ones (customer numbers, internal ids, anything specific to your
data).
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Test a Recognizer Pattern
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-test
POST /ai-gateway/pii-recognizers/test
Dry-run a PII pattern against sample text before you store it.
Runs a candidate `pattern` against a sample `text` and returns what it matched.
Nothing is stored and no recognizer is created.
`pattern` and `text` are both required — an empty pattern is
`400 invalid_pattern`, a missing `text` is `400 invalid_text`. Optional
`kind`, `action`, `context_words` and `name` let you rehearse the exact
recognizer you intend to save.
It compiles through the same validator the writer uses, so it can never
green-light a pattern that
[create](/api-reference/ai-gateway/pii-recognizers-create) would refuse.
This is the one operation here that is not tenant-scoped state: it reads nothing
and writes nothing. It still requires the `read` capability on `ai_gateway`.
See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Update a PII Recognizer
Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-update
PATCH /ai-gateway/pii-recognizers/{id}
Change a recognizer's pattern, action or context words.
Partial update. Omitted fields are left as they are. A new `pattern` is
re-validated through the safe-regex compiler, so an update cannot install a
pattern that a create would have refused.
Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Tokens (v1)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens
List, mint, and revoke an agent's phantom tokens over the public /v1 Management API. Plaintext is returned exactly once, at mint time.
# Phantom tokens
A **phantom token** is the short-lived capability token your application presents on the [data plane](/api-reference/ai-gateway/execute) to call an agent — your app never holds a raw provider API key. These endpoints manage the tokens for one agent over the `/v1` control plane. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication and the response envelope — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there.
The raw token value is returned **exactly once**, in the mint response. KnoxCall stores only a hash — list responses never contain plaintext. Save the minted token to your secrets manager immediately.
## Token format and environment
Tokens are formatted `kp____`, e.g. `kp_live_a_1a2b3c…`. The `prefix` field is the first 12 characters (`kp_live_a_` plus two characters), safe to display and store for identification.
**The prefix changed on 2026-09-01, with no compatibility window.** Phantom tokens used to be `kc_`-prefixed — the same family as OAuth 2.1 access tokens — and are now `kp_`. A token issued before that date no longer parses. Re-mint. See the [changelog](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix).
The environment segment is derived from the key you mint with — a test key mints a `kp_test_…` token, a live key mints a `kp_live_…` token. There is no environment parameter on the request.
| `kind` | Prefix letter | Use |
| - | - | - |
| `agent` | `a` | Full-scope agent invocations (default). |
| `read` | `r` | Read-only invocations. |
| `tool` | `t` | Tool-bound. |
| `oneshot` | `o` | One-shot ephemeral. |
## List tokens
```http theme={"dark"}
GET /v1/ai-gateway/agents/{agentId}/tokens
```
Returns the agent's tokens, paginated (`page`, `per_page`), newest first. Requires the `read` capability. **Never returns plaintext.** Returns `404 not_found` if the agent does not belong to your tenant or is a system agent.
```bash theme={"dark"}
curl "https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens" \
-H "Authorization: Bearer $TOKEN"
```
**Response**
```json theme={"dark"}
{
"data": [
{
"id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef",
"name": "CI pipeline token",
"kind": "agent",
"prefix": "kp_live_a_a4",
"dpop_required": false,
"scope_jsonb": { "providers": ["anthropic"], "models": ["claude-sonnet-4-6"] },
"expires_at": "2026-08-05T12:00:00.000Z",
"revoked_at": null,
"last_used_at": "2026-07-06T09:15:00.000Z",
"created_at": "2026-07-06T12:00:00.000Z"
}
],
"meta": {
"total": 1,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
## Mint a token
```http theme={"dark"}
POST /v1/ai-gateway/agents/{agentId}/tokens
```
Mints a new phantom token for the agent and returns the plaintext once. Requires the `mint` capability on `ai_gateway`.
**Request body** — all fields optional.
| Field | Type | Description |
| - | - | - |
| `name` | string | Label for the token. Defaults to `key-`. |
| `kind` | `agent` \| `read` \| `tool` \| `oneshot` | Token kind. Defaults to `agent`; an unrecognized value falls back to `agent`. |
| `dpop_required` | boolean | Bind the token to a DPoP key. Defaults to `false`. |
| `dpop_jkt` | string | Base64url SHA-256 JWK thumbprint of your DPoP public key. **Required when `dpop_required` is `true`.** |
| `expires_in_seconds` | number | TTL in seconds, clamped to `[60, 7776000]` (1 minute to 90 days). Omit for the default of 30 days; there is no way to mint a non-expiring token. |
The token's capability scope (providers and models) is derived automatically from the agent's `default_model`. It is not accepted from the request body.
```bash cURL theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CI pipeline token",
"kind": "agent",
"expires_in_seconds": 2592000
}'
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
agent = "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f"
resp = requests.post(
f"https://api.knoxcall.com/v1/ai-gateway/agents/{agent}/tokens",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"name": "CI pipeline token", "expires_in_seconds": 2592000},
)
token = resp.json()["data"]["token"] # shown only once
```
**Response**
```json theme={"dark"}
{
"data": {
"id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef",
"name": "CI pipeline token",
"kind": "agent",
"prefix": "kp_live_a_a4",
"token": "kp_live_a_a4b2...",
"dpop_required": false,
"expires_at": "2026-08-05T12:00:00.000Z"
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35",
"note": "Save this token now — it will not be shown again."
}
}
```
`expires_at` is always set: omitting `expires_in_seconds` gives the 30 days default rather than a permanent token. (Tokens minted before this default existed can still carry a `null` `expires_at`.) If `dpop_required` is `true` but `dpop_jkt` is missing, the request returns `400 validation` — a DPoP-bound token is unusable without the key thumbprint. See [Tokens & DPoP](/ai-gateway/tokens-and-dpop) for generating the thumbprint and sending DPoP proofs.
## Revoke a token
```http theme={"dark"}
DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId}
```
Immediately revokes the token. In-flight requests that have already passed auth complete; no new requests are accepted. Requires the `write` capability. Returns `404 not_found` if the token does not exist or is already revoked.
```bash theme={"dark"}
curl -X DELETE \
https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens/b1a2c3d4-e5f6-4789-abcd-1234567890ef \
-H "Authorization: Bearer $TOKEN"
```
**Response**
```json theme={"dark"}
{
"data": {
"id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef",
"revoked": true
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
# List Phantom Tokens
Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-list
GET /ai-gateway/agents/{agentId}/tokens
One agent's phantom tokens. Never returns plaintext.
Returns the agent's tokens, newest first, paginated. KnoxCall stores only a hash,
so **no response ever contains a token's plaintext** — `prefix` is the first 12
characters, enough to identify a token in a list and safe to store.
This endpoint lists tokens belonging to **this agent**. A token minted without an
agent — the OIDC exchange produces these — is invisible here; find those with
[List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list).
`expires_at` can be `null` on a token minted before 2026-08-30, when an omitted
expiry meant "never". Those are worth auditing: nothing will retire them for you.
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Mint Phantom Token
Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-mint
POST /ai-gateway/agents/{agentId}/tokens
Mint a short-lived capability token for an agent. The plaintext is returned exactly once.
The raw token is returned **once**, in this response. KnoxCall stores only a
hash — no later call can retrieve it. Save it to your secrets manager
immediately.
Tokens are `kp_`-prefixed since 2026-09-01 (`kp___…`) — see the
[changelog](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix);
a token minted before that date no longer parses.
Two behaviours worth knowing:
* **Omitting `expires_in_seconds` gives 30 days, not "never".** Any requested TTL
is clamped to between 60 seconds and 90 days. Before 2026-08-30 an omitted
expiry meant the token never expired.
* **The capability scope is empty, deliberately.** This endpoint offers no
parameter to set one, and an earlier revision *guessed* a provider restriction
from the model name — which mislabelled every Gemini, Mistral and Cohere agent
and would have refused the token's own traffic. An empty scope means "no
token-level restriction", which is the truth. See
[Scoping](/ai-gateway/tokens-and-dpop#scoping) for what a scope enforces when
one is present, and how to get one today.
`dpop_required: true` requires `dpop_jkt` in the same request — a DPoP-bound
token with no key thumbprint is unusable, so it is refused with
`400 validation` rather than minted.
**The dashboard twin.** The same operation exists on the admin host as
`POST /admin/ai-gateway/{gatewayId}/agents/{agentId}/keys`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# Revoke Phantom Token
Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-revoke
DELETE /ai-gateway/agents/{agentId}/tokens/{tokenId}
Immediately revoke an agent phantom token.
Revocation takes effect immediately: no new request is accepted. In-flight
requests that have already passed authentication complete.
Returns `404 not_found` if the token does not exist for this agent, or is already
revoked.
This endpoint addresses tokens **belonging to an agent**. A token minted without
an agent is invisible here — revoke it with
[Revoke a gateway token](/api-reference/ai-gateway/gateway-tokens-revoke).
**The dashboard twin.** The same operation exists on the admin host as
`DELETE /admin/ai-gateway/{gatewayId}/agents/{agentId}/keys/{tokenId}`, session-authenticated with an `X-Tenant-ID` header and an
owner/admin role. That is what the dashboard itself calls. Prefer the `/v1`
operation above for anything you write: it is the surface the five SDKs, the
Postman collection and this spec cover.
# Usage & Cost (v1)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/usage
GET /ai-gateway/usage
Read AI Gateway request counts, token counts, and estimated cost, grouped by model, over the public /v1 Management API.
# Usage & cost
Returns aggregated AI Gateway usage — request counts, input/output token counts, and estimated USD cost — broken down by provider and model, with tenant-wide totals. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication and the response envelope — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there.
## Get usage
```http theme={"dark"}
GET /v1/ai-gateway/usage
```
Requires the `read` capability on `ai_gateway`.
**Query parameters**
| Parameter | Type | Description |
| - | - | - |
| `period` | `7d` \| `30d` \| `90d` | Look-back window. Defaults to `30d`; any other value is treated as `30d`. |
| `agent_id` | string (uuid) | Optional. Restrict the aggregation to a single agent. |
```bash cURL theme={"dark"}
curl "https://api.knoxcall.com/v1/ai-gateway/usage?period=30d" \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication
resp = requests.get(
"https://api.knoxcall.com/v1/ai-gateway/usage",
headers={"Authorization": f"Bearer {TOKEN}"},
params={"period": "30d"},
)
usage = resp.json()["data"]
```
```javascript Node.js theme={"dark"}
const resp = await fetch(
"https://api.knoxcall.com/v1/ai-gateway/usage?period=30d",
{ headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }
);
const { data: usage } = await resp.json();
```
**Response**
```json theme={"dark"}
{
"data": {
"period_days": 30,
"by_model": [
{
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"requests": 1420,
"input_tokens": 3120544,
"output_tokens": 512310,
"cost_usd": 42.87,
"unpriced_requests": 0
},
{
"provider": "openai",
"model": "gpt-4o",
"requests": 260,
"input_tokens": 410233,
"output_tokens": 88110,
"cost_usd": 6.12,
"unpriced_requests": 4
}
],
"totals": {
"requests": 1680,
"input_tokens": 3530777,
"output_tokens": 600420,
"cost_usd": 48.99,
"unpriced_requests": 4
}
},
"meta": {
"request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35"
}
}
```
**Fields**
| Field | Description |
| - | - |
| `period_days` | The resolved look-back window in days (`7`, `30`, or `90`). |
| `by_model[]` | One row per `(provider, model)` seen in the window, ordered by descending cost. |
| `by_model[].requests` | Number of proxied requests. |
| `by_model[].input_tokens` / `output_tokens` | Summed token counts. |
| `by_model[].cost_usd` | Estimated cost in USD. |
| `by_model[].unpriced_requests` | Requests for which no price could be computed (model not in the price table); these contribute `0` to `cost_usd`. |
| `totals` | The same metrics summed across every `by_model` row. |
`cost_usd` is an estimate derived from KnoxCall's model price table. When `unpriced_requests` is non-zero, the true cost for those requests is higher than the reported `cost_usd`.
# Export Usage (CSV)
Source: https://docs.knoxcall.com/api-reference/ai-gateway/usage-export
GET /ai-gateway/usage/export
Aggregated AI Gateway spend as CSV, for showback and chargeback.
Exports aggregated spend for a period as CSV, grouped by the dimension your
finance process needs. `group_by` is a strict allowlist and a tag key is bound as
a query parameter, so there is no injection surface.
Every row carries `unpriced_requests` alongside `cost_usd`, so a pricing gap is
visible in the export rather than hidden inside a total. A model that is not in
the pricebook is recorded with `cost_source = 'unpriced'` — never booked as free.
**Cells are quoted and formula-neutralized.** Every cell is quoted, and any cell
beginning `=`, `+`, `-` or `@` is prefixed with a single quote, so a model
name or a tag cannot execute as a formula when someone opens the file in a
spreadsheet. If you parse this file, parse it as real CSV — do not split on
commas.
For the same data as JSON, use [Usage & cost](/api-reference/ai-gateway/usage).
Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types.
# Create API Key
Source: https://docs.knoxcall.com/api-reference/api-keys/create
Create a new API key for programmatic access to your tenant
## Create API Key
```text theme={"dark"}
POST /v1/api-keys
```
Creates a new API key. The full key value is returned **only once** in the response -- store it securely.
For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — short-lived tokens, scope narrowing, and no long-lived secret to store. See the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported; the examples below authenticate with a minted OAuth access token (`$TOKEN`).
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | A descriptive name (e.g., "CI/CD Pipeline", "Backend Server") |
| `rate_limit_requests` | integer | No | Maximum requests per window for this key |
| `rate_limit_window_sec` | integer | No | Rate limit window duration in seconds |
| `role_ids` | array of uuid | No | Permission roles to attach, assigned in the **same transaction** as the key. Max 20. Only the **seeded** key roles (`Key — Invoke`, `Key — Read-only`, `Key — Editor`, `Key — Infrastructure`) are accepted: custom roles are being retired in favour of per-key permissions. See [List Roles](/api-reference/api-keys/roles). |
The `key_type` is set automatically based on the host the request is sent to and **cannot** be specified in the request body. Requests to `api.knoxcall.com` produce a `standard` key; requests to `sandbox.knoxcall.com` produce a `test` key.
### Response
```json theme={"dark"}
{
"data": {
"key_id": "tk_9f3a1c2e",
"api_key": "tk_9f3a1c2e_4d2b8a1f0c9e7d6b5a4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e1",
"key_prefix": "tk_9f3a1c2e_",
"key_type": "standard",
"name": "CI/CD Pipeline",
"id": "f0a1b2c3-d4e5-6f7a-8b9c-0d1e2f3a4b5c",
"role_ids": [],
"message": "Store this API key securely. It will not be shown again."
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
The `api_key` field contains the full key and is **only returned at creation time**. KnoxCall does not store the plain-text key. If you lose it, you must revoke it and create a new one.
```bash cURL theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl -X POST https://api.knoxcall.com/v1/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CI/CD Pipeline"
}'
```
```python Python theme={"dark"}
import os
import requests
resp = requests.post(
"https://api.knoxcall.com/v1/api-keys",
headers={
"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}",
"Content-Type": "application/json"
},
json={"name": "CI/CD Pipeline"}
)
new_key = resp.json()["data"]
print(f"Store this key: {new_key['api_key']}")
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/api-keys", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ name: "CI/CD Pipeline" })
});
const { data: newKey } = await resp.json();
console.log(`Store this key: ${newKey.api_key}`);
```
### Permissions on the new key
A key created with **no** `role_ids` holds no policy, and is therefore
**default-denied on every policy-gated endpoint** — it authenticates, then 403s.
That is the platform default since the legacy `*:*` auto-attach was retired, and
it is almost never what you want.
**Roles govern the `/v1` management API, not calls to your routes.** The proxy
does not evaluate a key's roles today: any active key can call any enabled route
in its mode (Live or Test) — including a key holding only `Key — Read-only`, or
no role at all. What limits proxied calls is each route's own configuration:
**Require authorized clients** (set per environment — the caller must be one of
the route's clients, identified by source IP or mTLS certificate) and the route's
**IP allowlist**. An OAuth access token minted with explicit scopes is narrowed
on the proxy as well: it needs `routes:invoke:` for one route, or
`routes:invoke`, `routes:*` or `*:*` for every route. Management scopes such as
`routes:read` and `routes:write` do not let it call a route.
List the roles you can attach with
[`GET /v1/roles?subject_kind=api_key`](/api-reference/api-keys/roles) and pass
their ids:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/api-keys -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{ "name": "terraform-prod", "role_ids": ["b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"] }'
```
**A key can never mint a key more privileged than itself.** Every rule in every
requested role must be covered by a rule the *calling* credential already
holds. If it is not, the request is refused with `403 privilege_escalation`
and the response names the offending grant verbatim so you can see exactly
what was rejected:
```json theme={"dark"}
{
"error": {
"type": "privilege_escalation",
"message": "This API key cannot grant a permission it does not itself hold. Refused grant from role \"Key — Infrastructure\": {\"resource_type\":\"vault\",\"actions\":[\"create\"],\"effect\":\"allow\"} — no rule in your own policy set grants it.",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
Two grants can never be delegated at all by a key holding only a wildcard
(`*:*`) rule: `secret:reveal` and the custody / workload-binding actions.
Delegating one requires the calling key to name that exact
`(resource_type, action)` pair literally.
**A scope-narrowed OAuth access token cannot create API keys**, with or without
`role_ids`. An API key carries no scope narrowing, so any key such a token created
would exceed the token's own grant; the call returns `403 privilege_escalation`.
Create keys with an API key, or with an OAuth token whose scope is not narrowed
(no scope requested, or `*`).
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing `name` field; `role_ids` is not an array of UUIDs; more than 20 role ids; or a requested role's audience is `user`, not `api_key` |
| 403 | `privilege_escalation` | A requested role grants a permission the calling key does not itself hold (the message names the offending grant verbatim), or the call was made with a scope-narrowed OAuth token, which cannot create an API key at all |
| 403 | `forbidden` | A requested role is not one of the seeded key roles. Custom roles are being retired: a key that already holds one keeps it, but no new key can be given one |
| 404 | `not_found` | A `role_ids` entry does not exist in your tenant (another tenant's role id is indistinguishable from a nonexistent one, by design) |
# List API Keys
Source: https://docs.knoxcall.com/api-reference/api-keys/list
Returns a paginated list of API keys for your tenant
## List API Keys
```text theme={"dark"}
GET /v1/api-keys
```
Returns a paginated list of API keys for your tenant. Key values are **masked** -- only the prefix is shown.
For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — see the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported; the examples below authenticate with a minted OAuth access token (`$TOKEN`).
Results are filtered by the host you call. The live host (`api.knoxcall.com`) returns only `standard` and `access_key` keys, while the sandbox host (`sandbox.knoxcall.com`) returns only `test` keys.
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"key_id": "tk_a1b2c3d4",
"key_prefix": "tk_a1b2c3d4_",
"key_type": "standard",
"name": "Backend Server",
"active": true,
"created_at": "2026-01-15T09:30:00.000Z",
"last_used_at": "2026-03-10T14:22:01.000Z",
"rate_limit_requests": null,
"rate_limit_window_sec": null
}
],
"meta": {
"total": 3,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```bash cURL theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl https://api.knoxcall.com/v1/api-keys \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
resp = requests.get(
"https://api.knoxcall.com/v1/api-keys",
headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"}
)
keys = resp.json()["data"]
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/api-keys", {
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
const { data: keys } = await resp.json();
```
# Revoke API Key
Source: https://docs.knoxcall.com/api-reference/api-keys/revoke
Permanently revoke an API key, disabling all further access
## Revoke API Key
```text theme={"dark"}
DELETE /v1/api-keys/:id
```
Revokes an API key, permanently disabling it. Revoked keys cannot be reactivated.
For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — see the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | string | The API key ID (UUID or `key_id`) |
### Response
```json theme={"dark"}
{
"data": { "revoked": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
You cannot revoke the API key that you are currently using to authenticate the request. Use a different key to revoke it, or revoke it from the dashboard.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Cannot revoke the key currently in use |
| 404 | `not_found` | API key not found |
# List Roles
Source: https://docs.knoxcall.com/api-reference/api-keys/roles
Discover the permission roles you can attach to an API key
## List Roles
```text theme={"dark"}
GET /v1/roles
```
Returns your tenant's permission roles. This is the endpoint that makes
[`role_ids`](/api-reference/api-keys/create) usable from code: a Terraform module
or a provisioning script cannot hard-code a per-tenant UUID, and before this
existed the only way to find one was to open the admin UI and copy it out of the
URL bar.
**Read-only, permanently.** There is no `POST`, `PATCH` or `DELETE` on
`/v1/roles`.
Only the **seeded** key roles (`seeded: true`) are accepted in
[`role_ids`](/api-reference/api-keys/create). Custom roles are being retired in
favour of per-key permissions: no new one can be created, and this list still
returns any your tenant already has (`seeded: false`) — `POST /v1/api-keys`
refuses those with `403 forbidden`. Filter on `seeded` when choosing a role.
Requires the `role:list` permission, which all four seeded machine roles carry.
### Query Parameters
| Parameter | Type | Description |
| - | - | - |
| `subject_kind` | string | `api_key` or `user`. Returns only roles whose `applies_to` includes it. With `api_key`, the rows you can attach to a key are the ones with `seeded: true`. Any other value is a `400`. |
| `page` | integer | Page number (default `1`) |
| `per_page` | integer | Results per page (default `20`, max `100`) |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Key — Infrastructure",
"description": "For Terraform and other infrastructure-as-code runners.",
"applies_to": ["api_key"],
"is_default": false,
"seeded": true
},
{
"id": "c4e2d3f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"name": "Key — Read-only",
"description": "List/read config and analytics/log metadata.",
"applies_to": ["api_key"],
"is_default": true,
"seeded": true
}
],
"meta": {
"total": 2,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
| Field | Description |
| - | - |
| `applies_to` | Subject kinds the role may be assigned to. Passing a role whose `applies_to` is `["user"]` in `role_ids` returns a `400`. |
| `is_default` | Whether the admin UI prefills this role when creating a key. |
| `seeded` | `true` for roles KnoxCall creates and maintains. Seeded roles cannot be deleted, renamed or recomposed. Only a seeded key role is accepted in `role_ids`; `false` marks a custom role, which is being retired. |
The **rules** a role grants are deliberately not returned. Enumerating them
would hand every machine credential in the tenant a map of your authorization
surface. To see what a role grants, open it in the admin UI.
### The seeded machine roles
| Role | For |
| - | - |
| `Key — Invoke` | Intended for keys that **call** your APIs: reads the route/client/environment config needed to resolve a target, and runs workflows. No writes. Holding it is not what lets a key call a route — see the warning below. |
| `Key — Read-only` | List/read config and analytics metadata. The default for a fresh key. It does not stop the key calling your routes — see the warning below. |
| `Key — Editor` | CRUD on routes, clients, environments, webhooks and secrets. No key management, no billing, no permissions. |
| `Key — Infrastructure` | **Terraform and other infrastructure-as-code runners.** The whole provisioning surface: routes, environments, secrets, clients, API keys, webhooks, workflows, vaults, PKI, dynamic DB credentials, transit keys, AI gateways, OAuth clients and agents. |
**No role restricts which routes a key can call.** Roles are enforced on the `/v1`
management API. The proxy does not evaluate them: any active key can call any
enabled route in its mode (Live or Test), whatever roles it holds — so
`Key — Invoke` and `Key — Read-only` behave identically on proxied calls today.
Contain proxied use with each route's **Require authorized clients** setting and
**IP allowlist**, and with `routes:invoke` scopes on OAuth access tokens, which
the proxy does enforce.
`Key — Infrastructure` deliberately **excludes**, and explicitly denies:
* `secret:reveal` — a provisioning credential never needs the plaintext of a
secret it created. KnoxCall holds the plaintext; that is the product.
* `secret:custody_enable` / `custody_disable` / `custody_rotate` — custody
transitions decrypt the current administrative credential server-side.
* `workload_binding:create` / `delete` — a binding is a permanent route to a live
credential for anyone presenting a matching JWT.
* `ephemeral_proxy:invoke` — one-shot runtime invocation against live
credentials. It is not infrastructure state, and no Terraform resource models
it.
None of those four can be reached by a wildcard rule either: they require an
exact `(resource_type, action)` allow, so a legacy `*:*` key does not silently
hold them.
**Excluding `secret:reveal` does not make an Infrastructure key plaintext-free.**
It still holds operations that return plaintext or mint live credentials by
design — `transit:decrypt`, `vault:detokenize`, `secret:oauth_token_get`,
`pki:issue` (the leaf certificate's private key), `dyn_db_cred:mint` and
`ai_gateway:mint` — and it can create and update routes and workflows, which
inject your secrets into the requests they send. Re-pointing one at another host
sends the secret there. Treat an Infrastructure key as able to reach your
secrets, and store it accordingly.
### Examples
```bash cURL theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl "https://api.knoxcall.com/v1/roles?subject_kind=api_key" \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
kc = KnoxCall(tenant="acme")
roles = kc.roles.list(subject_kind="api_key")["data"]
infra = next(r for r in roles if r["name"] == "Key — Infrastructure")
key = kc.api_keys.create(name="terraform-prod", role_ids=[infra["id"]])
print(key["api_key"]) # shown once
```
```javascript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const kc = new KnoxCall({ tenant: "acme" });
const { data: roles } = await kc.roles.list({ subject_kind: "api_key" });
const infra = roles.find((r) => r.name === "Key — Infrastructure")!;
const key = await kc.apiKeys.create({ name: "terraform-prod", role_ids: [infra.id] });
console.log(key.api_key); // shown once
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | `subject_kind` was neither `api_key` nor `user` |
| 403 | `forbidden` | The calling key does not hold `role:list` |
# SHA-256 Hash Chain — Critical Audit Chain
Source: https://docs.knoxcall.com/api-reference/audit-logs/chain
Tamper-evident append-only audit chain for sensitive operations including BYOK KMS lifecycle, secret store migrations, and tenant impersonation
## SHA-256 Hash Chain — Critical Audit Chain
For sensitive operations — BYOK KMS lifecycle, secret store migrations, and tenant impersonation — each write goes into a **separate, append-only table** (`audit_log_critical`) that is distinct from the standard `audit_logs` table.
The critical chain is protected by:
* **Application-layer SHA-256 hash chain** — each row commits to the prior row via `prev_hash` (32 bytes) and `row_hash = SHA-256(canonical_bytes_of_this_row)`. Tampering with any row changes its `row_hash`, which breaks every subsequent `prev_hash` link. A verifier can detect the exact row that was altered.
* **Append-only enforcement** — database triggers prevent UPDATE and DELETE at the Postgres level. There is no retention pruning on this table; records exist indefinitely.
* **Serialized writes** — an advisory lock (`pg_advisory_xact_lock`) on every write ensures monotonic, gap-free `sequence_number` values. A gap in sequence numbers indicates a deletion attempt.
### Critical chain row schema
| Field | Type | Description |
| - | - | - |
| `sequence_number` | BIGINT | Monotonically increasing; gaps indicate tampering |
| `prev_hash` | BYTEA (32) | SHA-256 of the previous row's canonical bytes; all-zero for the genesis row |
| `row_hash` | BYTEA (32) | SHA-256 of this row's canonical bytes |
| `at` | TIMESTAMPTZ | Wall-clock timestamp of the action |
| `tenant_id` | UUID | Tenant subject of the action (nullable for operator-level events) |
| `actor_id` | UUID | User who performed the action (nullable for system-triggered events) |
| `action` | TEXT | One of the 15 action values listed above |
| `resource_type` | TEXT | One of the 7 resource types listed above |
| `resource_id` | TEXT | UUID or other identifier of the affected resource |
| `details` | JSONB | Action-specific context. Never contains plaintext secret values. |
### Canonical serialization
The `row_hash` is computed over a deterministic length-prefixed byte sequence:
```text theme={"dark"}
sequence_number (8 bytes, big-endian)
|| prev_hash (32 bytes, verbatim)
|| len(at_iso) + at_iso + sentinel
|| len(tenant_id) + tenant_id + sentinel
|| len(actor_id) + actor_id + sentinel
|| len(action) + action + sentinel
|| len(resource_type) + resource_type + sentinel
|| len(resource_id) + resource_id + sentinel
|| len(details_canonical_json) + details_canonical_json + sentinel
```
Where `details_canonical_json` is JSON with keys recursively sorted so the hash is deterministic regardless of insertion order. The sentinel byte distinguishes `null` (`\x00`) from empty string (`\x01`).
The authoritative implementation is `computeRowHash()` in `src/audit/critical.ts`. An external verifier can re-derive any row's hash from raw DB values and this spec.
### Verifying the chains
The critical chain (`audit_log_critical`, described above) is verified by `verifyChainSegment()` in `src/audit/critical.ts`, which walks the chain in `sequence_number` order, recomputes each `row_hash`, and stops at the first break. It runs nightly via the `verify-audit-chain` cron job. There is currently **no HTTP endpoint** that exposes the critical-chain verifier.
A separate verifier covers the standard `audit_logs` table. It is exposed through the dashboard admin API:
```bash theme={"dark"}
# Verify the current tenant's standard audit_logs hash chain.
# /admin/* lives on the dashboard host (admin.knoxcall.com / any
# knoxcall.com host), gated by your admin session JWT + X-Tenant-ID.
curl https://admin.knoxcall.com/admin/audit-logs/verify-chain \
-H "Authorization: Bearer $KC_SESSION_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
The endpoint scans the entire tenant chain (every `audit_logs` row with a `row_hash`, ordered by the monotonic `seq` column) and returns the verification result verbatim:
```json theme={"dark"}
{
"tenantId": "00000000-0000-0000-0000-000000000000",
"status": "verified",
"rowsChecked": 142,
"legacyRows": 0,
"unhashedRows": 0,
"chainTriggerArmed": true,
"tampered": 0,
"brokenChainAt": [],
"invalidHashAt": [],
"valid": true
}
```
`status` is the field to read; `valid` is simply `status === "verified"`.
| `status` | Meaning |
| - | - |
| `verified` | At least one row was checked and every check passed. |
| `invalid` | A stored `row_hash` or a chain link did not reproduce. The offending row `id`s are listed. |
| `unverifiable` | The answer carries no assurance — **not** the same as clean. `unverifiableReason` says which: `trigger_not_armed` (the writer trigger is missing or disabled, so new rows are landing outside the chain), `unhashed_rows` (rows exist that the chain does not cover), or `no_rows` (the verified segment was empty). |
`legacyRows` counts rows written before the chain-ordering fix (migration 20260819); they are reported, not verified, until an operator re-chains history.
A failed verification sets `status` to `invalid` and lists the offending row `id`s — `brokenChainAt` for rows whose `prev_hash` does not match the prior row's `row_hash`, and `invalidHashAt` for rows whose stored `row_hash` does not match the recomputed value:
```json theme={"dark"}
{
"tenantId": "00000000-0000-0000-0000-000000000000",
"status": "invalid",
"rowsChecked": 142,
"legacyRows": 0,
"unhashedRows": 0,
"chainTriggerArmed": true,
"tampered": 1,
"brokenChainAt": [],
"invalidHashAt": ["9f1c2b64-6d0a-4a3e-8b21-0d7a5f3c9e10"],
"valid": false
}
```
Note that this endpoint verifies the standard `audit_logs` chain, whose `row_hash` is the hex SHA-256 of the UTF-8 bytes of
```text theme={"dark"}
prev_hash + tenant_id + COALESCE(user_id, actor_user_id_snapshot) + action
+ resource_type + resource_id + details::text + ip_address
```
— a plain string concatenation with each term replaced by the empty string when null, distinct from the length-prefixed canonical serialization of the critical chain above. `details::text` is Postgres's own jsonb rendering, not `JSON.stringify`. `actor_user_id_snapshot` is a copy of the acting user's id kept outside foreign-key control: `audit_logs.user_id` is `ON DELETE SET NULL`, so without it, deleting a user would rewrite a column their rows had already been hashed over and every one of them would report as tampered. The endpoint takes no range parameters; it always scans the whole tenant chain.
# List Audit Logs
Source: https://docs.knoxcall.com/api-reference/audit-logs/list
Returns a paginated list of audit log entries for your tenant configuration changes
## List Audit Logs
```text theme={"dark"}
GET /v1/audit-logs
```
Returns a paginated list of audit log entries, newest first. You can filter by action type and resource type.
### Who sees `details`
`details` records what changed: before and after values, names, and addresses. It is always
**sanitised**. Values under credential-shaped keys (`client_secret`, `access_token`, and so on)
read `***REDACTED***`, and `user:password@` userinfo is removed from any URL. Identifiers are kept,
such as the acting key's `api_key_id`.
| Caller | `details` |
| - | - |
| API key or OAuth client with `audit_log:list` | Sanitised `details`, `details_redacted: false` |
| User-bound token (the `knoxcall` CLI, device or authorization-code grant) whose holder is an **Admin or Owner** | Sanitised `details`, `details_redacted: false` |
| User-bound token whose holder is a Viewer or Editor | `details: {}` and `details_redacted: true` |
The rest of the row (action, resource, IP address and time) is returned to every caller. A
redacted `details` is always an empty object, never a string, so typed clients decode it
unchanged. `details` itself is always an object or `null`: an entry whose stored details are not a
JSON object (a bare string, array or number from a self-hosted agent) is returned sanitised and
wrapped as `{"value": ...}`. The same rule applies to `GET /v1/audit-logs/events`
and to the dashboard's Audit Trail.
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
| `action` | string | -- | Filter by action (e.g., `create`, `update`, `delete`) |
| `resource_type` | string | -- | Filter by resource type (e.g., `route`, `secret`, `client`, `webhook`, `environment`, `api_key`) |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"action": "create",
"resource_type": "route",
"resource_id": "rt-uuid-1",
"details": {
"name": "Payment Gateway",
"target_base_url": "***REDACTED***"
},
"details_redacted": false,
"ip_address": "203.0.113.42",
"created_at": "2026-03-10T14:22:01.000Z"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"action": "update",
"resource_type": "secret",
"resource_id": "sec-uuid-1",
"details": {
"field": "value",
"environment": "production"
},
"details_redacted": false,
"ip_address": "203.0.113.42",
"created_at": "2026-03-10T14:20:00.000Z"
}
],
"meta": {
"total": 487,
"page": 1,
"per_page": 20,
"total_pages": 25,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
// List all audit logs
const page = await client.auditLogs.list();
const logs = page.data;
// Filter by action and resource type
const filtered = await client.auditLogs.list({ action: "delete", resource_type: "route" });
const deletions = filtered.data;
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
# List all audit logs
page = client.audit_logs.list()
logs = page["data"]
# Filter by resource type
filtered = client.audit_logs.list(resource_type="secret", per_page=50)
secret_logs = filtered["data"]
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# List all audit logs
curl "https://api.knoxcall.com/v1/audit-logs" \
-H "Authorization: Bearer $TOKEN"
# Filter by action and resource type
curl "https://api.knoxcall.com/v1/audit-logs?action=delete&resource_type=route" \
-H "Authorization: Bearer $TOKEN"
```
### Common Action Types
Standard CRUD actions appear on any resource type:
| Action | Description |
| - | - |
| `create` | A resource was created |
| `update` | A resource was modified |
| `delete` | A resource was deleted |
| `enable` | A resource was enabled |
| `disable` | A resource was disabled |
| `revoke` | An API key was revoked |
Critical audit chain actions — these go into the separate `audit_log_critical` table (see below):
| Action | Description |
| - | - |
| `byok.kms.onboard` | Customer KMS provider onboarded (BYOK) |
| `byok.kms.rotate` | Tenant master key rotated |
| `byok.kms.unseal` | Tenant unsealed after KMS access restored |
| `byok.kms.revoke` | Tenant master key version explicitly revoked (cryptographic erasure) |
| `migration.create` | Secret store migration created |
| `migration.cancel` | Migration cancelled |
| `migration.item.reveal` | Reviewer revealed plaintext value of a migration item |
| `migration.item.approve` | Reviewer approved a migration item |
| `migration.item.reject` | Reviewer rejected a migration item |
| `migration.item.expire` | Migration item TTL expired (cron-driven) |
| `migration.commit` | Operator committed the migration as complete |
| `migration.credentials.expired` | Migration provider credentials expired |
| `migration.db_proxy_route.create` | DB proxy route created for a migration |
| `migration.db_proxy_route.delete` | DB proxy route deleted |
| `tenant.impersonate` | KnoxCall support admin impersonated a tenant |
### Common Resource Types
General audit log resource types:
| Resource Type | Description |
| - | - |
| `route` | API proxy route |
| `secret` | Encrypted secret |
| `client` | Authorized client |
| `webhook` | Webhook configuration |
| `environment` | Environment definition |
| `api_key` | API key |
| `environment_config` | Route environment override |
Critical audit chain resource types (see `audit_log_critical` below):
| Resource Type | Description |
| - | - |
| `tenant_kms_config` | BYOK KMS configuration |
| `tenant_master_key` | Tenant master key version |
| `secret_migration` | Secret store migration job |
| `secret_migration_item` | Individual item within a migration |
| `migration_db_proxy_route` | DB proxy route record for a migration |
| `migration_credential` | Provider credential record for a migration |
| `tenant_impersonation_session` | Admin impersonation session |
Audit log entries are immutable and cannot be modified, but they are pruned according to your subscription plan's data retention policy — a scheduled cleanup deletes `audit_logs` rows older than the plan's retention window. The separate critical audit chain (`audit_log_critical`, see `chain` below) is never pruned and is retained permanently.
# Authentication
Source: https://docs.knoxcall.com/api-reference/authentication
Authenticate with the KnoxCall API — SDK and OAuth 2.1 + DPoP first, with long-lived API keys as a supported legacy alternative
KnoxCall supports two authentication paths, both fully supported, both safe to mix:
1. **OAuth 2.1 short-lived access tokens** (`kc_live_…`, `kc_test_…`) — minted via `POST /oauth/token`, expire in 1 hour, optionally sender-constrained with DPoP (RFC 9449). This is what the SDKs use under the hood, and the recommended path for new integrations.
2. **Long-lived API keys** (`tk_live_…`, `tk_test_…`, `AKE…`) — the original path, still works everywhere; treat it as the legacy alternative.
If you've used Auth0, Okta, AWS Cognito, Stripe Connect, or any modern OAuth provider, our OAuth surface will feel identical — every endpoint conforms to a published IETF RFC.
## Quick start — OAuth 2.1
```bash theme={"dark"}
# Mint a 1-hour token from your existing API key
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "tk_abcd1234:rest_of_key" \
-d "grant_type=client_credentials" \
| jq -r .access_token)
# Use it like any Bearer token
curl -H "Authorization: Bearer $TOKEN" https://api.knoxcall.com/v1/routes
```
Your existing `tk_…` API key becomes the OAuth `client_id` + `client_secret`:
* `client_id` = `tk_abcd1234` (everything before the second underscore)
* `client_secret` = the remainder
The OAuth path adds:
* **1-hour TTL** instead of long-lived
* **Scope narrowing** — request a subset of your key's permissions per call
* **DPoP sender-constraint** (optional) — proof-of-possession of a keypair required per request
### OAuth clients that are not API keys
A client created on the **OAuth Clients** page (or via `POST /v1/oauth-clients`) has no API key
behind it. Its `client_credentials` tokens call `/v1` with whatever **roles** the client holds —
assign them when you create the client, or later on its **Permissions** tab. A client with no
role is denied on every `/v1` endpoint, and its `allowed_scopes` narrow each token further;
a token can never do more than both its scope and the client's roles allow.
A token issued to a *user* (authorization-code and device flows, including `knoxcall login`) calls
`/v1` as that **user**: their own roles in the workspace, narrowed by the token's scope. Whatever
roles or custom policies the user holds, a user-bound token:
* **needs an enrolled second factor** to act on the Live host;
* **never performs a human-plane action** — revealing a secret's plaintext, managing permissions,
changing custody. Those need a signed-in session with MFA and step-up, not a bearer token;
* **never uses a reveal-class permission** — decrypting (`transit:decrypt`), detokenizing a vault
value, minting a database credential, issuing a certificate that comes back with its private key
(from the PKI engine, or a client's mTLS certificate with `mode: issue` — registering a
certificate you generated yourself by its `sha256_thumbprint` still works), or fetching a live
upstream OAuth2 token — **and never gives one to a machine credential**: it cannot attach a role
carrying one to a new API key, or rotate the secret of an OAuth client that holds one. Automation
that needs these uses an API key or OAuth client that an owner or admin grants the permission in
the dashboard;
* **uses owner and admin capabilities only for an owner or admin** of the workspace, as in the
dashboard: listing, minting and revoking API keys; creating, changing and deleting OAuth apps;
registering agents; changing AI gateways and minting their tokens; managing transit keys and
rewrapping or signing with them (on `/v1`, managing includes listing keys and reading a key's
metadata, which the dashboard shows to any member); managing PKI and database credentials;
vaults; webhook test sends; and the ephemeral proxy;
* **needs an edit permission, not only a read one, to mint or revoke a wrap token or to report
egress observations** — `secret:update` for `POST` / `DELETE /v1/wrap/tokens`, `route:create`
for `POST /v1/wrap/egress-observations`. An API key keeps doing these on read alone.
These are the only bounds a user-bound token adds to the user's roles.
A custom role or policy is part of a user's roles only when it contains a `deny` rule (or any
rule that is not a well-formed `allow`). One made only of `allow` rules is no longer held by any
person (see the 2026-10-08 entry in the [changelog](/api-reference/changelog)), and a custom role
can no longer be assigned to a person. One that contains a `deny` rule stays on the person who
holds it, and on `/v1` both its denies and its allows still apply. API keys and OAuth clients are
not affected.
## OAuth 2.1 endpoints
| Endpoint | Purpose | RFC |
| - | - | - |
| `POST /oauth/token` | Mint access + refresh tokens | RFC 6749 |
| `POST /oauth/device_authorization` | Start a device-code flow (headless machines) | RFC 8628 |
| `POST /oauth/introspect` | Validate a token (relying-party use) | RFC 7662 |
| `POST /oauth/revoke` | Revoke a token | RFC 7009 |
| `GET /.well-known/oauth-authorization-server` | Discovery | RFC 8414 |
Supported grant types:
* `client_credentials` — server-to-server, the simplest path
* `authorization_code` + PKCE — human consent flow (with refresh tokens). Public clients may register loopback redirect URIs per RFC 8252 — `http://127.0.0.1`, `http://[::1]`, and `http://localhost` match on any port at authorization time, so native and CLI apps can receive the callback locally.
* `urn:ietf:params:oauth:grant-type:device_code` — RFC 8628 device-code flow for headless/SSH machines: `POST /oauth/device_authorization` returns a user code, the user approves it at `/oauth/activate` on any browser, and the device polls the token endpoint. This is what `knoxcall login --device` uses.
* `refresh_token` — single-use rotation with family-detection theft protection
* `urn:ietf:params:oauth:grant-type:token-exchange` — workload identity federation. **Any OIDC issuer works**; the SDKs additionally auto-detect several platforms for you (see below)
## DPoP — sender-constrained tokens (RFC 9449)
For tenants requiring the highest security tier, request DPoP-bound tokens. Send a DPoP proof JWT on the token request; the issued token is bound to your keypair via the `cnf.jkt` claim. Every subsequent API call must carry a fresh DPoP proof signed by the same key.
```typescript theme={"dark"}
// Use the SDK — DPoP is transparent
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: "acme", dpop: "always" });
```
## Workload identity federation (RFC 8693)
Running on a platform that issues its workloads an OIDC token? **You don't need an API key at all.** Register a binding, and the exchange trades that token for a short-lived KnoxCall access token.
**Any OIDC issuer is accepted** — if it publishes `/.well-known/openid-configuration` over https and signs with any asymmetric algorithm (the full `RS`/`PS`/`ES` family plus `EdDSA` — see [What your issuer's token must carry](#what-your-issuer-s-token-must-carry) below), you can federate from it by registering the issuer and the claims that identify your workload.
**Auto-detection is a convenience on top of that.** Where the SDK recognises the platform it fetches the token for you with no configuration: GitHub Actions, Google Cloud, AWS (IRSA), Azure managed identity, Vercel, CircleCI and Buildkite. **Coverage differs by language** — the Node SDK covers all seven, the Python SDK covers five (no Azure, no Buildkite). On any other platform, or from an SDK that does not detect yours, fetch the token yourself and call the exchange directly; HCP Terraform works this way today.
**Which claims identify your workload depends on the issuer.** KnoxCall carries a claim
table for three issuers — GitHub Actions (`repository`, `job_workflow_ref` or `sub`),
HCP Terraform (`sub`) and Google (`email` or `sub`) — and requires your binding to pin at
least one of them. Every other issuer falls back to `sub` alone, which works but is a
blunter instrument: `sub` is the only claim OIDC guarantees identifies a subject. This
matters because a binding that pins no identifying claim would describe a *population*
rather than a workload — on a shared issuer like GitHub's, `{"ref": "refs/heads/main"}` is
true for every repository on GitHub — so the API refuses one with `400`.
```yaml theme={"dark"}
# GitHub Actions example — zero stored secrets
- run: |
npm install @knoxcall/sdk@^1.0.0
node my-script.js
env:
KNOXCALL_TENANT: acme
permissions:
id-token: write
```
Configure the trust binding once in the KnoxCall dashboard under **Settings → Workload Identity**. Subsequent CI runs auto-authenticate.
### What your issuer's token must carry
These are checked on every exchange, on both `POST /oauth/token` and
`POST /v1/oauth/token`. Every mainstream IdP satisfies them by default; they are listed
because a refusal that names a missing claim is easier to fix than one that does not.
| Requirement | Why |
| - | - |
| **A `kid` in the JWS header** | It names which of the issuer's published keys signed the token. Required even when the issuer publishes only one key — otherwise the rule would switch itself off for the length of a key rotation, which is precisely when key selection needs to be unambiguous. |
| **An `exp` claim** | It is the only claim that bounds how long a captured assertion stays usable. A token with no `exp` is refused rather than treated as never expiring. |
| **An `iss` matching a registered binding** | The issuer URL is compared to your binding exactly, as a string. |
| **An asymmetric `alg`** | One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`. HMAC algorithms (`HS*`) and `none` are always refused — the only key material we hold for your issuer is its *public* JWKS, so accepting an HMAC would mean verifying a token signed with the key we ourselves published. |
Assertions are **single use**. Each one is spent by the exchange that accepts it, so a
long-running job must mint a fresh token from its platform before each exchange rather
than reusing the first — the SDKs' `WorkloadCredentialProvider` does this for you.
## Create an OAuth client
An OAuth client is how an SDK or a script authenticates in CI and on servers. (On a developer
machine you can [log in once with the CLI](#log-in-once-with-the-cli) instead.) You create it
once in the dashboard:
1. Switch the dashboard to the mode you want to call, **Live** or **Test**. A client belongs to
the mode it was created in.
2. Open **Settings** → **API** → **OAuth Clients** and click **Create OAuth Client**.
3. Enter a **Name** (for example `my-laptop`) and keep the type **Server-to-server**.
4. Under **Allowed scopes**, list the scopes the client needs, separated by spaces. For example,
`routes:read routes:invoke` lets it list your routes and call them through the proxy; add
`routes:write` or `secrets:write` if it will create them. An empty list is refused.
5. Under **Permissions**, keep **Key — Read-only** (selected by default), or choose
**Key — Editor** if the client will create or change routes, secrets or clients. A client with
no role is refused on every `/v1` endpoint.
6. Click **Create Client**, then copy the **Client ID** and **Client secret**. The secret is shown
once.
Put the pair in the environment. Every SDK reads it with no code change:
```bash theme={"dark"}
export KNOXCALL_CLIENT_ID="tk_xxxxxxxx" # the Client ID
export KNOXCALL_CLIENT_SECRET="..." # the Client secret
```
A client created in Test mode calls the Test environment, so also pass the SDK's `sandbox`
option (`sandbox: true` in Node and Ruby, `sandbox=True` in Python, `Sandbox: true` in Go,
`'sandbox' => true` in PHP).
## Using the SDK (recommended)
KnoxCall ships first-party SDKs for Node.js/TypeScript, Python, Go, PHP, and Ruby (plus a Terraform provider and browser/React Elements) — see the [SDKs overview](/sdks/overview) for the full family. Every SDK handles token minting, caching, retries, idempotency keys, and DPoP for you.
```bash Node.js / TypeScript — install theme={"dark"}
npm install @knoxcall/sdk@^1.0.0
```
```typescript Node.js / TypeScript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: "acme" });
const routes = await client.routes.list();
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
# Credentials from KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
client = KnoxCall()
page = client.routes.list()
routes = page["data"]
```
```go Go theme={"dark"}
client, err := knoxcall.New(knoxcall.Options{})
if err != nil {
log.Fatal(err)
}
page, err := client.Routes.List(context.Background(), nil)
// page.Data holds the routes
```
```php PHP theme={"dark"}
$client = new KnoxCall(); // env-configured
$page = $client->routes->list();
$routes = $page['data'];
```
```ruby Ruby theme={"dark"}
client = KnoxCall::Client.new # env-configured
page = client.routes.list
routes = page["data"]
```
```bash cURL (raw REST) theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see Quick start above
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN"
```
Every SDK handles credential auto-detection, token caching, refresh, retries, and DPoP signing — your code just calls methods. Prefer raw REST? Mint a short-lived OAuth token and pass it as `Authorization: Bearer ` (a legacy `tk_…` key in the same header also works) and parse the JSON response.
## Log in once with the CLI
On a developer machine, skip keys and env vars entirely. Every KnoxCall SDK package ships the `knoxcall` command (pip, npm, gem, Composer, `go install` — same surface, same credentials file); it runs the authorization-code + PKCE flow in your browser (or the RFC 8628 device-code flow with `--device`) and stores the credential in `~/.knoxcall/credentials.json`:
```bash theme={"dark"}
pip install "knoxcall>=1.0,<2.0" # or your ecosystem's equivalent — every SDK ships the CLI
knoxcall login # opens your browser (PKCE); prints the URL too
knoxcall login --device # headless/SSH machines: device-code flow
knoxcall whoami # show the signed-in tenant
knoxcall logout # revoke + remove the stored credential
```
Your browser opens the KnoxCall dashboard. Sign in if you are not already, check what is being authorized, choose the workspace and click **Approve** — the terminal finishes on its own. With `--device`, open the link the terminal prints on any device, confirm the code it shows, and approve there. Approving needs an account with a passkey, security key or authenticator app set up.
`knoxcall login` signs you in to **Live**. To call the Test environment, use an [OAuth client](#create-an-oauth-client) created in Test mode and the SDK's `sandbox` option.
Every KnoxCall SDK — Node.js, Python, Go, PHP, and Ruby — picks the file up automatically, so a zero-argument constructor just works:
```python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # tenant and base URL come from the login
```
Access tokens refresh themselves; refreshes are cross-process safe (file lock + atomic rotation of the single-use refresh token). Behind the scenes, `knoxcall login` uses the reserved `knoxcall-cli` client alias, which provisions a per-tenant system OAuth client on first login — you'll see it on the OAuth Clients page, where it is visible but protected. See the [SDKs overview](/sdks/overview#log-in-once-with-the-cli) for profiles (`KNOXCALL_PROFILE`) and the full credential-resolution order.
## Postman / Insomnia / Bruno
Download the official collection and import it into Postman. It uses Postman's native OAuth 2.0 support — sign in once via the **Authorization** tab, and every saved request inherits the token.
For other tools, point them at the OAuth 2.0 discovery URL: `https://api.knoxcall.com/.well-known/oauth-authorization-server`.
***
## API Key Types
KnoxCall uses API keys to authenticate requests to the Management API. There are three types of keys, each with a distinct prefix:
| Key Type | Prefix | Environment | Description |
| - | - | - | - |
| Standard | `tk_live_` | Production | A live key. On the management API it can do only what its attached [roles](/api-reference/api-keys/roles) allow — with no role it is refused on every policy-gated `/v1` endpoint, and can reach only the API root and the two endpoints that describe the key itself (`GET /v1/account`, `GET /v1/account/usage`). Available on all plans. |
| Enterprise Access | `AKE` | Production | A live access-key pair (access key ID + secret access key). Enterprise plan only. It is created with no roles and the default 90-day expiry; neither scopes nor expiry are chosen when you create it. |
| Test | `tk_test_` | Sandbox | A sandbox key, authorized by its roles exactly like a standard key. Safe for development and testing. |
Key prefixes make it easy to identify which environment a key belongs to at a glance. This follows the same live/test paradigm used by Stripe and other modern APIs.
**A key's roles do not limit which of your routes it can call.** Roles are
enforced on the `/v1` management API only; the proxy does not evaluate them, so
any active key can call any enabled route in its mode, whatever roles it holds.
Contain proxied use with each route's **Require authorized clients** setting and
**IP allowlist** — see [Creating API keys](/api-reference/api-keys/create).
## Creating API Keys
### Via the Dashboard
1. Log in to the [KnoxCall Dashboard](https://knoxcall.com).
2. Navigate to **Settings** in the sidebar.
3. Scroll to the **API Keys** section.
4. Click **Create API Key**.
5. Give your key a descriptive name (e.g., "CI/CD Pipeline", "Backend Server").
6. Copy the key immediately -- it will only be shown once.
API keys are displayed only once at creation time. If you lose a key, you must revoke it and create a new one. KnoxCall never stores keys in plain text after initial creation.
### Via the API
You can also create API keys programmatically:
```bash theme={"dark"}
# $TOKEN is a minted OAuth access token — see the Quick start above
curl -X POST https://api.knoxcall.com/v1/api-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Deployment Key"
}'
```
Response:
```json theme={"dark"}
{
"data": {
"key_id": "ak_3f8a9b2c",
"api_key": "tk_live_newkey789...",
"key_prefix": "tk_live_newk",
"key_type": "standard",
"name": "Deployment Key",
"message": "Store this API key securely. It will not be shown again."
},
"meta": {
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
The `api_key` field in the response is the only time the full API key is returned. Store it securely.
## Production vs. Sandbox
KnoxCall provides completely separate production and sandbox environments, following the same live/test key paradigm popularized by Stripe. Routes, secrets, environments, webhooks, vaults, crypto keys, PKI authorities, OAuth clients, and workflows are all isolated between the two modes — nothing leaks across.
**Management API:** `https://api.knoxcall.com/v1`
**Proxy traffic:** `https://{slug}.knoxcall.com/{route}`
* Uses `tk_live_` or `AKE` prefixed keys
* Routes proxy real traffic to live upstream APIs
* Secrets contain real credentials
* Usage counts against your plan limits
* Changes affect your live integrations immediately
**Management API:** `https://sandbox.knoxcall.com/v1`
**Proxy traffic:** `https://sandbox-{slug}.knoxcall.com/{route}`
* Uses `tk_test_` prefixed keys
* Routes, secrets, and clients are isolated from production
* Safe for development, testing, and CI/CD
* No impact on production traffic or billing
* Mirrors production API behavior exactly
Using a test key against the production API (or vice versa) will return a `403` error with the type `wrong_key_type`. Make sure your environment configuration matches the correct base URL and key type.
### Dashboard toggle
The admin dashboard has a **Live / Test** toggle in the bottom-left of the sidebar (look for the flask icon). Click it to switch the entire dashboard between modes — every page (Routes, Secrets, Vaults, API Keys, Webhooks, etc.) refetches and shows only the data for the active mode. An amber banner across the top of the screen confirms when you are in Test mode.
While in Test mode:
* Creating an API key automatically produces a `tk_test_` test key (the standard / Enterprise Access Key options are hidden).
* Creating any other resource (route, secret, vault, etc.) stamps it as sandbox — it is invisible from Live mode.
* Existing live resources are not visible; you start with an empty sandbox until you populate it.
Switching back to Live mode restores the production view immediately. Your selection persists across browser sessions.
### Using the SDK in sandbox mode
Both the Node and Go SDKs accept a `sandbox: true` option which auto-resolves the management and proxy base URLs to the sandbox hostnames:
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({
tenant: "acme",
sandbox: true, // → https://sandbox.knoxcall.com + https://sandbox-acme.knoxcall.com
});
// Use a tk_test_ key — required for sandbox.
```
```go Go theme={"dark"}
import "github.com/knoxcall/sdk-go/knoxcall"
client, err := knoxcall.New(knoxcall.Options{
Tenant: "acme",
Sandbox: true, // → https://sandbox.knoxcall.com + https://sandbox-acme.knoxcall.com
APIKey: "tk_test_...",
})
```
Equivalent environment variables work everywhere (CI, Docker, etc.): set `KNOXCALL_BASE_URL=https://sandbox.knoxcall.com` and `KNOXCALL_PROXY_BASE_URL=https://sandbox-{slug}.knoxcall.com`.
### Audit logs
Every audit log entry is stamped with `details.sandbox: true|false` so you can filter live vs test activity in the Audit Logs page or via the `/v1/audit-logs` endpoint. Background jobs (cron, queue workers) always emit `sandbox: false`.
## Making Authenticated REST Requests
The [SDK](#using-the-sdk-recommended) is the recommended path — it mints, caches, and refreshes tokens for you. Calling the REST API directly, mint a short-lived OAuth token (see [Quick start](#quick-start--oauth-21)) and pass it in the `Authorization` header as a Bearer token:
### Examples
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials)
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
headers = {
# OAuth access token minted via POST /oauth/token
"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"
}
# List all routes
response = requests.get(
"https://api.knoxcall.com/v1/routes",
headers=headers
)
routes = response.json()["data"]
for route in routes:
print(f"{route['name']} - {route['target_base_url']}")
```
```javascript Node.js theme={"dark"}
// OAuth access token minted via POST /oauth/token
const TOKEN = process.env.KNOXCALL_ACCESS_TOKEN;
const response = await fetch("https://api.knoxcall.com/v1/routes", {
headers: {
Authorization: `Bearer ${TOKEN}`
}
});
const { data: routes } = await response.json();
routes.forEach(route => {
console.log(`${route.name} - ${route.target_base_url}`);
});
```
```php PHP theme={"dark"}
true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$token}"
]
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
foreach ($data['data'] as $route) {
echo "{$route['name']} - {$route['target_base_url']}\n";
}
```
### Legacy: Long-Lived API Keys
Long-lived keys (`tk_live_…`, `tk_test_…`, `AKE…`) remain fully supported on the Management API. Pass the key exactly where the token goes:
```bash theme={"dark"}
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer tk_live_abc123..."
```
### Legacy: Using the x-api-key Header
If your HTTP client or framework makes it difficult to set the `Authorization` header, you can pass a long-lived API key in the `x-api-key` header instead:
```bash cURL theme={"dark"}
curl https://api.knoxcall.com/v1/routes \
-H "x-api-key: tk_live_abc123..."
```
```python Python theme={"dark"}
import requests
response = requests.get(
"https://api.knoxcall.com/v1/routes",
headers={"x-api-key": "tk_live_abc123..."}
)
```
```javascript Node.js theme={"dark"}
const response = await fetch("https://api.knoxcall.com/v1/routes", {
headers: { "x-api-key": process.env.KNOXCALL_API_KEY }
});
```
```php PHP theme={"dark"}
true,
CURLOPT_HTTPHEADER => [
"x-api-key: " . getenv('KNOXCALL_API_KEY')
]
]);
$response = curl_exec($ch);
curl_close($ch);
```
## Authentication Errors
When authentication fails, the API returns one of these error types:
| Error Type | Status | Cause | Solution |
| - | - | - | - |
| `authentication_required` | 401 | No API key provided | Add the `Authorization` or `x-api-key` header to your request |
| `invalid_api_key` | 401 | Key is invalid, revoked, or expired | Check that the key is correct and has not been revoked in the dashboard |
| `wrong_key_type` | 403 | Test key used on production, or live key used on sandbox | Match the key type to the correct base URL |
| `subscription_inactive` | 403 | Tenant subscription is paused or cancelled | Update your billing information in the dashboard |
Example error response:
```json theme={"dark"}
{
"error": {
"type": "invalid_api_key",
"message": "The API key provided is invalid or has been revoked.",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
## Security Best Practices
An API key can call your routes with your credentials injected, and do whatever its roles allow on the management API. Never embed one in frontend JavaScript, mobile apps, or any code that runs in the browser.
Store API keys in environment variables or a secrets manager. Never hard-code them in source files or commit them to version control.
Create new keys and revoke old ones on a regular cadence. If a key is compromised, revoke it immediately from the dashboard.
Attach the narrowest role each key needs — roles limit what it can do on the management API — and create a separate key per service so you can revoke one without the others. Roles do not limit which routes a key can call: require authorized clients or an IP allowlist on each route.
### Environment Variable Setup
On a developer machine, prefer `knoxcall login` — no environment variables at all. For CI and servers, [create an OAuth client](#create-an-oauth-client) (dashboard → **Settings → API → OAuth Clients**) and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` — every SDK picks them up automatically. `KNOXCALL_API_KEY` remains supported as the legacy spelling for a long-lived key.
```bash Linux / macOS theme={"dark"}
# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.)
export KNOXCALL_CLIENT_ID="..."
export KNOXCALL_CLIENT_SECRET="..."
# Or use a .env file with dotenv
echo 'KNOXCALL_CLIENT_ID=...' >> .env
echo 'KNOXCALL_CLIENT_SECRET=...' >> .env
# Legacy long-lived key (still supported)
export KNOXCALL_API_KEY="tk_live_abc123..."
```
```powershell Windows (PowerShell) theme={"dark"}
# Set for current session
$env:KNOXCALL_CLIENT_ID = "..."
$env:KNOXCALL_CLIENT_SECRET = "..."
# Set permanently for current user
[System.Environment]::SetEnvironmentVariable("KNOXCALL_CLIENT_ID", "...", "User")
[System.Environment]::SetEnvironmentVariable("KNOXCALL_CLIENT_SECRET", "...", "User")
```
```yaml Docker / docker-compose.yml theme={"dark"}
services:
app:
environment:
- KNOXCALL_CLIENT_ID=...
- KNOXCALL_CLIENT_SECRET=...
# Or reference a .env file
env_file:
- .env
```
```yaml CI/CD (GitHub Actions) theme={"dark"}
# Prefer workload identity federation — zero stored secrets (see
# "Workload identity federation" above). Otherwise store the client
# secret as a repository secret and reference it:
env:
KNOXCALL_CLIENT_ID: ${{ secrets.KNOXCALL_CLIENT_ID }}
KNOXCALL_CLIENT_SECRET: ${{ secrets.KNOXCALL_CLIENT_SECRET }}
```
Never add `.env` files containing credentials to version control. Add `.env` to your `.gitignore` file.
### Key Rotation Procedure
1. Create a new API key in the [dashboard](https://knoxcall.com) or via the API.
2. Update your application's environment variables with the new key.
3. Deploy the updated configuration.
4. Verify that requests succeed with the new key.
5. Revoke the old key in the dashboard.
Both the old and new keys will work simultaneously until you revoke the old one. This allows zero-downtime rotation.
# Changelog
Source: https://docs.knoxcall.com/api-reference/changelog
Dated, notable changes to the KnoxCall Management API and first-party SDKs.
# API Changelog
Notable changes to the KnoxCall Management API (`/v1`) and the first-party SDKs. Newest first.
Backwards-compatible additions (new fields, new endpoints, new error types) ship without a
version bump; we call out anything that changes existing behavior.
**A change to existing behavior, in the dashboard (`/admin`) and on the Management API (`/v1`).**
A route can carry a standing `decrypt` or `detokenize` field action
(`POST /v1/routes/{routeId}/actions`). Attaching one already needed the action's own permission
(`transit:decrypt` / `vaults:detokenize`), and a token issued to a person could never do it.
**Moving the route it runs on did not:** an Editor could change its base URL in the dashboard,
and any key with `routes:write` could on `/v1`, and every later call through the route then sent
the opened values to the new host (a request-side action), or opened whatever the new host
returned (a response-side action).
**What changes.** Giving such a route a destination it does not already store — a new
`target_base_url`, a new `method_configs[].target_url` (active or dormant), or a new environment
carrying a URL the route has never had, including the environments a collection's defaults
provision (apply to routes, reset to defaults, provision from the collection, moving the route
into a collection with default environments) — or making it **start sending** to a URL it only
stores (turning on a disabled environment or method-specific configs, allowing a method that
reaches a base URL the allowed methods used to bypass, copying an idle URL into a base URL, or
duplicating an environment whose URL the route only stores into one that serves traffic, so
a URL written while nothing asked cannot be switched on later; a URL counts as already sent to
only when a `GET`, `POST`, `PUT`, `PATCH` or `DELETE` request reaches it today, and a change is
judged as if the environment's switches were open, so two saves sent together cannot each slip
through and combine):
* **in the dashboard** is an **Owner or Admin** act with a fresh second factor (a security key,
passkey or authenticator code), an optional reason, the shared reveal budget, and a
`plaintext.reveal` audit entry (`"surface": "route.destination_change"`, naming the routes, the
new hostnames and the actions) written **before** the change. **Editors lose this**, with or
without **Reveal secrets**. Refusals: `403 reveal_role_denied` for an Editor or Viewer;
`403` with `"code": "reveal_step_up_required"` and `"action_scope": "plaintext.reveal"` until an
Owner or Admin verifies (the dashboard asks and retries); `429` / `503` from the budget;
`503 reveal_audit_unavailable`. Applying a collection to several routes takes **one**
verification.
**One Save asks once.** The route page sends everything a Save changes in an environment (the
Target URL, headers and body rules, allowed methods, security defaults and the **Serve traffic**
switch, which is now staged like the other settings instead of applying on click) as one
request, so a Save asks for the verification at most once and a cancelled or refused
verification saves nothing. Pushing a Target URL to every environment is one request and one
verification, all or nothing. The audit entry for such a Save names everything it changed. A
double-click on any of the page's commands sends the change once and asks once, and while one
is being saved, every other control on the page that can change where the route sends is
disabled. Also fixed:
**Sync environment** and the compare panel's copies of the signature and rate-limit settings
now save them; on an environment that already had a configuration they were reported as synced
and not stored. A save that finishes after you switch environment no longer puts the previous
environment's settings back on the page (the next per-method change could store them in the
environment you switched to).
* **on `/v1`** (`PATCH /v1/routes/{id}`, `PUT /v1/routes/{id}/environments/{env}`): a token issued
to a **person** is refused `403 forbidden`, whatever the role; a **machine** credential needs
the action's own permission — `403 forbidden` if the key's policy lacks `transit:decrypt` on the
action's key or `vault:detokenize` on its vault, `403 insufficient_scope` for a narrowed OAuth
token. Allowed changes are recorded as `route.destination_change` before they are written.
Nothing is written on any refusal; do not retry it unchanged.
* **the onboarding assistant's** route tools refuse it and point to the route page.
**What does not change.** Re-sending a URL the route already stores, removing one, turning an
environment off, deleting an environment override, duplicating or renaming an environment
(onto URLs the route already sends to), and every route without a
`decrypt` / `detokenize` action (an `encrypt` or `tokenize` action closes data and does not
count). No request or response shape changes; `GET /admin/routes/{id}` gains `opening_actions`
(kind and direction) for the dashboard's note.
**A change to existing behavior on the Management API (`/v1`).** `HEAD` is not a documented
operation on any `/v1` endpoint and no first-party SDK sends one, but the API answers it the
way a web server does: it runs the `GET` and returns the status and headers without the body.
On the read endpoints listed below that happened without the permission check the `GET` makes.
A credential whose role or policy refused the `GET` with `403 forbidden` was answered on a
`HEAD` with the real status and `Content-Length`. It never received a response body, but it
could tell whether a route, client, webhook or request-log entry with a given id existed, and
how large a list was.
A `HEAD` to these endpoints is now refused exactly as the `GET` is, with the same
`403 forbidden`:
* `/v1/api-keys`
* `/v1/clients`, `/v1/clients/{id}`, `/v1/clients/{id}/credentials`
* `/v1/environments`
* `/v1/routes`, `/v1/routes/{id}`, `/v1/routes/{id}/logs`, `/v1/routes/{id}/environments`
* `/v1/webhooks`, `/v1/webhooks/event-types`, `/v1/webhooks/hmac-formats`,
`/v1/webhooks/{id}`, `/v1/webhooks/{id}/logs`
* `/v1/audit-logs`, `/v1/audit-logs/events`
* `/v1/logs`, `/v1/logs/{request_id}`, `/v1/logs/{request_id}/proof`
`/v1/routes/{routeId}/actions` already checked a `HEAD`, but made one of the two checks its
`GET` makes. It now makes both.
**Nothing changes for a credential that may make the `GET`.** Its `HEAD` is answered as
before. Every other `/v1` endpoint already made the same permission check on a `HEAD` as on
a `GET`.
One related change on the same endpoints, for requests that were never served. Each group of
endpoints recognizes a fixed set of request methods (not every URL in a group has an operation
for each of them):
* `/v1/api-keys`: `GET`, `POST`, `DELETE`
* `/v1/audit-logs` and `/v1/logs`: `GET`
* `/v1/clients`, `/v1/environments`, `/v1/routes` and `/v1/webhooks`: `GET`, `POST`, `PUT`,
`PATCH`, `DELETE`
`HEAD` counts as `GET` in every group, and `OPTIONS` is unchanged. What a request with any
other method receives depends on which side of its group's set the method falls:
* **A method outside the set** is now answered `405 method_not_allowed` in the standard error
envelope, with an `Allow` response header that lists the group's methods:
`Allow: GET, HEAD, POST, DELETE` on `/v1/api-keys`, `Allow: GET, HEAD` on `/v1/audit-logs`
and `/v1/logs`, and `Allow: GET, HEAD, POST, PUT, PATCH, DELETE` on `/v1/clients`,
`/v1/environments`, `/v1/routes` and `/v1/webhooks`. No role or policy permission is checked
for it, so the answer does not depend on what the credential may do. Examples: a `PATCH` to
`/v1/api-keys/{id}`, a `POST` to `/v1/audit-logs`, or a method such as `PROPFIND` on any of
these endpoints. Before this change such a request was answered `404`, in the plain body
described in the next item, so code that matched on that `404` should match on `405`. The
`Allow` header names the methods of the endpoint group, not of the one URL: a method it
lists can still have no operation at a particular URL (the next item), and it does not list
`OPTIONS`. Two checks still come first: a request with no valid credential is answered
`401`, and an access token restricted by OAuth scope is answered `403 insufficient_scope` if
its scope does not cover the endpoint.
* **A method inside the set that the particular URL has no operation for is unchanged.**
Examples: a `PUT` to `/v1/webhooks/{id}`, or a `DELETE` to `/v1/webhooks/{id}/logs`. The
permission that method maps to on the endpoint group is checked first (`webhook:update` and
`webhook:delete` in those two examples), so a credential without it is answered
`403 forbidden`. A credential that holds it is answered `404` as before, and not in the
standard error envelope: the body is a plain
`{"error": "not_found", "message": "No route for …"}`, or plain text for a `GET`.
**Two webhook endpoints now ask for their own permission however the URL is written.**
`GET /v1/webhooks/{id}/logs` requires `webhook:read_logs`, and `POST /v1/webhooks/{id}/test`
requires `webhook:test`. The API also accepts those URLs with a trailing slash or in another
letter case, and for those spellings it checked `webhook:read` and `webhook:create` instead.
A credential allowed to read webhooks but not their delivery logs could read the logs at
`/v1/webhooks/{id}/logs/`, and one allowed to create webhooks but not to send a test delivery
could send one at `/v1/webhooks/{id}/test/`. Every spelling now requires `webhook:read_logs`
and `webhook:test`. If a request to one of those spellings starts returning `403 forbidden`,
the credential was relying on the weaker check: grant it the permission the endpoint names.
**A change to existing behavior, for people only.** API keys and OAuth clients keep every role
and policy they hold, including a custom role they share with a person.
A person's access in a workspace comes from their team role. Until now a person could also
hold a custom role, or a custom policy attached to them directly, from before those stopped
being assignable to people.
**What is removed.** From each person:
* a custom role, when every policy in it contains only `allow` rules;
* a custom policy attached to them directly, when it contains only `allow` rules.
The role or policy itself is not deleted or changed, and it stays on any API key or OAuth
client that holds it.
**What is left in place.** A custom role or policy that contains a `deny` rule — or any rule
that is not a well-formed `allow` — stays on the person, with everything in it, because
removing a deny would give them access they do not have today. Its `allow` rules keep applying
as well. The roles KnoxCall provides are not touched.
**What a person can lose** is whatever the removed role or policy allowed beyond their team
role:
* on `/v1`, with a token issued to them (`knoxcall login`, the device grant, or an app they
authorized with the authorization-code flow), those calls now answer **403 `forbidden`**;
* in the dashboard, the `details` of audit-log entries, if the custom role or policy was what
let them read it;
* when they create an API key or OAuth client, the roles they may give it. Those are bounded by
what the person is allowed, so that bound can shrink with it.
Nothing a person was denied becomes allowed.
**The record.** Each person who lost a role or policy has one audit-log entry in the
workspace, `membership.custom_grants_retired`, naming what was removed, what was kept and why.
Its actor is empty: KnoxCall made the change, not a member.
If someone needs that access back, give them the team role that covers it, or give the
automation an API key or OAuth client with a key role.
**A change to existing behavior in the dashboard (`/admin`).** Two more actions join the Class B
reveals in the 2026-10-06 entry below. **Who can do them does not change:** both were already
Owner and Admin only, and still are.
* **Issuing an mTLS client certificate** on a client's **Credentials** section ("Issue a new
cert", `POST /admin/clients/{id}/credentials` with `"kind": "mtls_thumbprint"` and
`"data": { "mode": "issue" }`). The response carries the certificate's private key, so it now
works exactly like issuing a certificate from a certificate authority
(`POST /admin/pki/roots/{name}/issue/{role}`): a fresh second factor, an optional reason, a
`plaintext.reveal` audit entry written **before** the key is generated, and the shared reveal
budget. If the check refuses, no certificate is issued and no credential is added.
**Registering a certificate you generated yourself** (`"data": { "sha256_thumbprint": … }`) or
an IP address hands nothing back and is unchanged: an Editor can still do it, with no second
factor.
* **The PII token-map view** of the AI gateway (`GET /admin/ai-gateway-pii/token-map/{conversationId}`),
which decrypts a conversation's tokenized values and shows the first three characters and the
length of each. Three characters and an exact length narrow a card number, an SSN or an email
a long way, so it is now a reveal: a fresh second factor, a `plaintext.reveal` audit entry
naming the conversation **before** anything is decrypted, and the shared reveal budget. A
request the gateway cannot address (a name with no `?principal=`) is still a `400`, answered
before anything is asked for or recorded.
The refusals are the ones listed below: `403` with `"code": "reveal_step_up_required"` and
`"action_scope": "plaintext.reveal"` until you verify (the dashboard asks for your factor and
retries), `503 reveal_audit_unavailable` if the entry cannot be written, and `429` /
`503` from the budget. The entries these actions already wrote (`pki.leaf.issue` with
`via: client_credential`, and the credential's own `create` entry) are still written.
**Nothing on the Management API (`/v1`) changes.** `POST /v1/clients/{id}/credentials` with
`"mode": "issue"` still refuses a token issued to a person (2026-09-30), and a machine credential
holding `client:create` still issues. There is no `/v1` token-map endpoint.
**A change to existing behavior in webhook delivery.** Besides audit-log entries, the
`audit.event` stream carries a record of proxied requests. Until this change that record was
delivered to every `audit.event` webhook in the workspace, Live and Test alike: a Test
request's record reached your Live endpoint, signed with the Live webhook's signing secret and
listed in the Live webhook's delivery log, and a Live request's record reached your Test
endpoint the same way. It now goes only to the `audit.event` webhooks in the request's own
mode, as the `request.*` events always have. A request record whose mode cannot be determined
is delivered to neither.
Audit-log entries (configuration changes, sign-ins, rotations) are unchanged. They belong to the
workspace rather than to a mode, and are still delivered to `audit.event` webhooks in both
modes.
**A change to existing behavior, in the dashboard (`/admin`) and on the Management API (`/v1`).**
It finishes what the 2026-10-06 entry below started for captured requests.
**What changes.** A route's saved sample payload — `payload_structure`, which the dashboard
saves from a payload capture or a pasted sample, and each method's copy of it in
method-specific mode — is now returned as its **structure** on every read, for every role: the
same keys and nesting, with each value replaced by its type, `""`, `""` or
`""` (`null` stays `null`; a list keeps each item, shown the same way). That covers
the route page and its environments, the responses to saving a sample, the workspace export,
the before-and-after details of a route change in the audit log, `GET /v1/routes/{id}`, the
response to `PUT /v1/routes/{id}/environments/{env}` (which returned the sample with no masking
at all, including inside `method_configs`), and the configuration bundle sent to a self-hosted
agent. Until now every member, a Viewer included, could read the values, with only values
under sensitive-looking field names masked.
**The values are a reveal.** An Owner or Admin can click **Show sample values** on the route's
**Body** tab (`GET /admin/routes/{id}/payload-sample?environment={env}`, with `&method={METHOD}`
for one method's sample). It works like the other reveals in the 2026-10-06 entry: a fresh
second factor, an optional reason, a `plaintext.reveal` audit entry written before the values
are read, and the shared reveal budget. Values under sensitive-looking field names are still
masked there. Editors and Viewers are refused (`403` with `"code": "reveal_role_denied"`).
Asking for a sample that is not saved — a method with no sample of its own, or an environment
with none — is a `404` before anything is asked for, counted or recorded.
**On `/v1` there is no way to read the values**, with any credential. The field's type does not
change (it is still an object), so no SDK changes a signature; the SDKs, the API reference and
the Postman collection now describe the field as structure only.
**Saving is unchanged.** Pasting or capturing a sample still stores what you sent; reading it
back shows its structure — including to the Editor who pasted it. Saving a route's settings
without touching its sample keeps the stored values, and so does saving the sample editor
unchanged. On `/v1`, `PATCH /v1/routes/{id}` with a `method_configs` whose samples are the
structure you read back keeps each stored sample too. Editing the sample saves what you typed: to edit the values, reveal them first.
Copying one method's configuration to another, or syncing an environment's configuration from
another environment in the dashboard, copies the sample's structure, not its values (a target
sample with the same structure keeps its own values). Duplicating an environment still copies
everything, values included, on the server. The workspace export carries the structure only: an export is not a reveal (it asks for no second
factor), so it does not carry the values.
**A change to existing behavior in the dashboard (`/admin`).** Nothing on the Management API
(`/v1`) changes here: a credential still needs the matching permission there. Since the
2026-09-30 entry a token issued to a person never carries `transit:decrypt`,
`vault:detokenize`, `pki:issue` or `dyn_db_cred:mint`; listing a vault's tokens and transit
rewrap on `/v1` are unchanged by this entry and do not take the check below.
**What is a Class B reveal.** Each of these hands you plaintext, a live credential, or captured
traffic, and each now runs through the same reveal check as showing a secret's value (the
2026-10-05 entry below):
* listing a vault's tokens (`GET /admin/vaults/{vault}/tokens`) and detokenizing one
(`GET /admin/vaults/{vault}/tokens/{token}`);
* transit decrypt and rewrap (`POST /admin/crypto/keys/{name}/decrypt`, `/rewrap`);
* issuing a dynamic database credential
(`POST /admin/dyn-db-credentials/connections/{name}/creds/{role}`);
* issuing a certificate and its private key (`POST /admin/pki/roots/{name}/issue/{role}`);
* creating a **server-mode** agent (`POST /admin/agents` with `"mode": "server"`), rotating a
server-mode agent's secret, creating an install token, and switching an agent to proxy mode
(`switch_to_proxy`, or `switch_mode` to `proxy`);
* creating a **Can view** or **Can manage** share (`POST /admin/secrets/{id}/shares`);
* the secret-store migration review: an item's value, approve, reject and commit;
* a request log's captured headers and bodies (`GET /admin/logs/{request_id}?include=payload`)
and its archived bodies (`GET /admin/logs/{request_id}/file`);
* a webhook delivery's payload (`GET /admin/webhook-logs/{id}?include=payload`);
* an outbound webhook's signing secret (`GET /admin/webhooks/{id}/secret`);
* retrieving a request captured at a route's payload-capture URL
(`GET /admin/routes/{id}/check-payload-capture/{token}`, once the request has arrived).
**Who.** The Owner and Admins only. An Editor cannot, even with **Reveal secrets** turned on
(that setting covers secret values, certificates and credentialed workflow tests). Where the
route's own Owner/Admin check answers first, the refusal is that check's `403`; otherwise it is
`403` with `"code": "reveal_role_denied"`. The migration review also still needs the
Owner-granted review delegation for an Admin, checked first. Every surface in the list was
already Owner and Admin only except one: an Editor could retrieve a request captured at a
payload-capture URL, and now cannot. An Editor can still start a capture, but only the person
holding the capture link can retrieve it and a capture expires after 5 minutes, so in practice
retrieving a capture is now an Owner and Admin action. **This does not yet cover the saved
result:** the dashboard saves a retrieved capture as the route's sample payload, and every
member of the workspace can still read that sample (on the route's pages and in the `/v1`
route response), with values under sensitive-looking field names masked. The 2026-10-07 entry
above puts those reads behind the same Owner and Admin rule.
**Every use asks for a fresh second factor** (an authenticator code, a passkey or a security
key; never an emailed code), and one verification buys one use. The refusal is `403` with
`"code": "reveal_step_up_required"`, `"requires_step_up": true` and the scope to verify for:
`"plaintext.reveal"`, or `"secret.migration"` for the migration review (the scope those four
actions have always taken; they now take it once per action, inside the same check). The
dashboard asks for your factor and retries, and offers an optional reason on an explicit reveal.
**The audit entry comes first.** Each use writes a `plaintext.reveal` entry before anything is
decrypted, minted, read or changed; if it cannot be written the request is refused with
`503 reveal_audit_unavailable`. Each use also counts against the per-person reveal budget
shared with secret values (60 per 5 minutes; `429 reveal_budget_exceeded`, or
`503 reveal_budget_unavailable` when it cannot be counted). The entries these actions already
wrote (`vault.detokenize`, `transit.decrypt`, `pki.leaf.issue`, `migration.item.reveal` and the
rest) are still written.
**Captured payloads are an act of their own.** Opening a request log or a webhook delivery now
shows its **metadata** to everyone (status, timing, path, response code, any error). Its
captured headers and bodies are withheld until an Owner or Admin clicks **Reveal payload**;
downloading or previewing an archived body is a reveal of its own, so previews no longer load
when you open the page. The seeded roles only ever gave captured payloads to Owners and Admins,
so an Editor or Viewer on a seeded role loses nothing they had; one holding a **custom**
`log:read_payload` grant loses the payload. A tenant policy that denies `log:read_payload`,
`audit_log:read_details` (for an `audit.event` delivery) or `webhook:reveal_secret` still
refuses an Admin, with `"code": "reveal_policy_denied"`, before the verification is spent.
**Also changed.** A vault's **Tokens** tab no longer lists the tokens when you open it: click
**Show tokens**. Transit rewrap now refuses a platform operator who is not a member of your
workspace inside the handler, as decrypt always has. An **intercept** agent, switching an agent
back to intercept, a **Use only** share and the signing secret returned when a webhook is
created or regenerated are not reveals and are unchanged.
**A change to existing behavior for Editors and Viewers.** The Management API (`/v1`) is
unchanged. This is the signed-in `/auth` route behind Settings → Account.
**What changed.** `PATCH /auth/me` saves your own profile (name, company name, phone,
timezone, date format, notification and consent settings) and, from the same form, six
company billing fields: `country`, `address`, `city`, `postal_code`, `tax_id_type` and
`tax_id_value`. Those six belong to a workspace, not to you. They are written to the workspace
and to its billing customer, and they print on its invoices. Until this change anyone who
could sign in could save them, whatever their role, and they were saved to the first
workspace you joined. For someone who accepted an invitation, that is the workspace that
invited them. Now a request that carries any of the six answers `403` unless you are the
Owner or an Admin of that workspace:
```json theme={"dark"}
{
"error": "Only the workspace's Owner or an Admin can change its company billing address or tax ID. Nothing was saved.",
"code": "tenant_billing_requires_owner_or_admin",
"tenant_id": ""
}
```
**When it refuses, nothing is saved**, including any profile fields sent in the same request.
Send those on their own and they save as before. A request that carries none of the six is
unchanged for every role. Your own role in that workspace decides it: a KnoxCall platform
operator is treated the same way as anyone else.
**The same request also needs what the Billing page needs.** Owners and Admins are affected by
two more conditions:
* **An enrolled second factor.** Without one, the request answers `403` with
`"error": "mfa_required"` and an enrolment link, exactly as every dashboard route does.
* **A session that belongs to that workspace.** Some sign-ins produce a session that belongs to
one workspace: a workspace's own single sign-on, accepting an invitation to a workspace, a
magic link sent for a workspace, a passkey used on a workspace's own address, and finishing
two-factor sign-in on any of those. Such a session can change the six fields only for that
workspace. For any other workspace the answer is `403` with "This session is scoped to a
different tenant. Sign in again to switch tenants."
The workspace-session rule also applies to onboarding (`POST /auth/complete-onboarding`) when it
would update a workspace you already own; the second-factor requirement does not apply there.
**A country or postal code the workspace cannot store is now a `400` before anything is
saved.** `country` must be a two-letter code (for example `NZ`, `AU`, `US`) and `postal_code`
at most 20 characters, the same rule signup and the New Tenant dialog already apply. The answer
is `400` with an error starting `Invalid tenant address:`. Before this change, a value like
`"USA"` saved your profile fields first and then failed with a `500`.
**Which workspace.** The six fields still go to the first workspace you joined, not to the one
selected in the dashboard. This change does not alter that. The Billing page's tax ID
(`PUT /admin/billing/tax-id`) acts on the selected workspace and has always required the Owner
or an Admin.
**A change to existing behavior in the dashboard, for every role.** This entry changes the
dashboard's own routes (`/admin`) only. Nothing on the Management API (`/v1`) changes here:
the `/v1` endpoints that return decrypted or live credential material — among them
`GET /v1/secrets/{id}/oauth2/token` and `GET /v1/vaults/{vault}/tokens/{token}` (see the
2026-08-26 entry) — keep the controls they have today, and changes to them will come in a later
change with its own entry. For what a token issued to a person can reach on `/v1`, see the
2026-09-30 entry "A token issued to a person no longer reaches owner/admin or reveal-class
operations on /v1".
**What asks for a second factor now.** Five dashboard actions hand you plaintext, and each one
now needs a fresh verification, every time, whoever you are (Owner, Admin, or an Editor with
**Reveal secrets** turned on):
* showing a secret's value (`GET /admin/secrets/{id}/value`);
* showing the value of a secret another workspace shared with yours (the same route, on the
shared secret's entry in your workspace);
* viewing or downloading a certificate (`GET /admin/secrets/{id}/certificate/download`), with
or without its private key — a `pfx`, `p12` or `key` certificate *is* its private key;
* testing a workflow step against your stored credentials
(`POST /admin/workflows/{id}/nodes/{nodeId}/test`);
* reading a workflow's webhook trigger URL (`GET /admin/workflows/{id}/webhook-url`), whose
token is the trigger's bearer credential.
Before this change only two of these asked for one: revealing a secret shared from another
workspace, and a platform operator's emergency access to a secret's value or certificate. Your
own workspace's secrets, certificates and workflows did not.
**The factor.** An authenticator code, a passkey or a security key. An emailed code does not
count. Without a verification the request answers `403` with `"code": "reveal_step_up_required"`,
`"requires_step_up": true`, `"action_scope": "plaintext.reveal"` and
`"allowed_methods": ["passkey", "2fa"]`; the dashboard asks for your factor and retries. A
verification buys one reveal: the next reveal asks again. A verification made for any other
action does not count here, and one made here counts nowhere else.
**The audit row comes first.** Every granted reveal writes a `plaintext.reveal` audit entry —
the surface, the environment, Live or Test, when you verified, and an optional reason you can
type in the dashboard (sent as the `X-Reveal-Reason` header) — **before** the value is
decrypted. A shared secret's value belongs to the workspace that shared it, so that workspace
gets a `plaintext.reveal` entry of its own too, also before the value is decrypted. That entry
records your workspace and the request's IP address rather than your user account, and does
not carry your reason. If either entry cannot be recorded, the reveal is refused with
`503 reveal_audit_unavailable`.
**Rate limit, failing closed.** Reveals share one per-person budget (60 per 5 minutes). Over it,
`429 reveal_budget_exceeded` with `Retry-After`. If the budget cannot be counted (our rate-limit
store is unavailable), the reveal is refused with `503 reveal_budget_unavailable` and
`Retry-After`, never served unmetered. The same `503` now also applies to the other reveal
screens (vault tokens, imported-credential review, captured request bodies), which used to
answer that case with a `429`.
**Who may reveal is unchanged**: the Owner and Admins, and an Editor only with
**Reveal secrets** turned on. A refusal for that reason answers `403` with
`"code": "reveal_role_denied"` (a Viewer) or `"code": "reveal_tick_required"` (an Editor
without the setting), and does not offer a verification. Testing a workflow step is the one
exception for a Viewer: the test runs the step for real, which a Viewer may never do, so a
Viewer is refused earlier, by the read-only check, with a `403` that carries no `code`.
**If a check cannot run** (our database did not answer), the reveal is refused, never served: a
check that could not run is never treated as passed. When it is one of the reveal checks
(your membership, your **Reveal secrets** setting or your verification) the answer is `500`
with `"code": "reveal_check_failed"`; when it is the workspace or resource lookup that runs
before them, it is a `500` without that code. Retry it.
**No role gains or loses a permission, and the Management API (`/v1`) is unchanged.** These
are changes to the signed-in `/auth` routes behind Settings → Security and the signup flow.
**Adding a passkey now asks for the same verification as adding an app or a security key.** On
an account that already holds any factor (an authenticator app, a security key or a passkey),
`POST /auth/passkey/register-options` answers `403` with `"requires_step_up": true`,
`"action_scope": "mfa.credential"` (it was `null`) and `"allowed_methods": ["passkey", "2fa"]`
until you verify for `mfa.credential`. A verification made for anything other than managing
your sign-in factors (revealing a secret, approving an OAuth consent, exporting your data)
no longer counts here. One made for another factor-management action (adding an app or a
security key, removing a key or passkey, disabling or re-keying the app) now counts here,
because they share the `mfa.credential` scope; before this change it did not, since only a
verification tied to no action counted. An account with no factor yet still adds its first
passkey with the emailed code from `POST /auth/passkey/challenge`, and no verification.
**`POST /auth/passkey/challenge` names the same scope when it declines.** For an account that
already holds a factor it mails no code, as before, and its `403` now carries
`"action_scope": "mfa.credential"` (it was `null`): the verification the next call needs.
**The verification `POST /auth/2fa/verify` records when you confirm your first factor is now
for `mfa.credential`** (it was not tied to any action). Signup is unchanged: adding a passkey
straight after setting up the app still needs no second prompt. In the five minutes after it,
that verification no longer counts for actions outside factor management, such as an OAuth
consent; those ask you to verify again. It now counts instead for the other factor-management
actions (adding a security key, removing a key or passkey, disabling or re-keying the app),
which it did not before.
**No role gains or loses a permission, and the Management API (`/v1`) is unchanged.** These
are changes to the signed-in `/auth` routes behind the dashboard's "Verify your identity"
step and the OAuth consent page.
**Security keys can step up.** An account whose only second factor is a security key could
not complete a step-up verification before, so every action that asks for one was closed to
it: revealing a secret, downloading a certificate's private key, tenant KMS unseal, rotate and
revert, the migration routes, minting OAuth-client and SCIM credentials, SAML changes,
ownership transfer, and removing a factor. Two new endpoints fix that:
`POST /auth/2fa/webauthn/step-up-options` returns a WebAuthn challenge for the keys and
passkeys the account holds, and `POST /auth/2fa/webauthn/verify-step-up` (`credential`,
`challenge`, `action_scope`) records the verification. It is recorded as `passkey`, so any
`403` whose `allowed_methods` lists `passkey` can now be cleared with a security key. An
emailed code still does not count at any of these gates.
**Adding an authenticator app now asks for the factor you already have.** On an account that
holds a security key or a passkey, `POST /auth/2fa/setup` answers `403` with
`"requires_step_up": true`, `"action_scope": "mfa.credential"` and
`"allowed_methods": ["passkey", "2fa"]` until you verify with that key or passkey for
`mfa.credential`, the same rule that already applied to re-keying an app. A verification made
for a different action does not count. Before this change a signed-in session alone could add
an app. An app setup started before this change on such an account, and never confirmed, must
be started again: confirming it answers `400 "2FA not set up. Call /2fa/setup first."`.
**Confirming an app (`POST /auth/2fa/verify`) leaves no verification behind for an account
that already had a factor.** After a re-key, or after adding an app to a security key or
passkey, the next action that asks for a step-up asks again. An account enrolling its very
first factor is unchanged.
**New `409 mfa_enrolment_changed`** from `POST /auth/2fa/verify`, when the app setup was
restarted elsewhere (another tab) between your code being checked and the app being saved.
Nothing is enabled or replaced; scan the newest QR code and enter a code from it.
**Adding a security key now asks for the same verification as adding an app.** On an account
that already holds any factor (an authenticator app, a security key or a passkey),
`POST /auth/2fa/webauthn/register/options` answers `403` with `"requires_step_up": true`,
`"action_scope": "mfa.credential"` (it was `null`) and `"allowed_methods": ["passkey", "2fa"]`
until you verify for `mfa.credential`. A verification made for anything other than managing
your sign-in factors (revealing a secret, approving an OAuth consent, exporting your data)
no longer counts here. One made for another factor-management action (removing a key or
passkey, disabling or re-keying the app) now counts here, because they share the
`mfa.credential` scope; before this change it did not, since only a verification tied to no
action counted. An account with no factor yet still enrols its first security key without
one. *(Corrected 2026-10-05: this paragraph first said such a verification "still" counted.)*
**`POST /auth/2fa/setup` can answer the same `409 mfa_enrolment_changed`**, when an account
with no factor yet starts or restarts app setup while another tab is confirming the app.
Nothing is written: the app confirmed in the other tab stays the live one, with its backup
codes.
**A change to existing behavior on your routes' proxy traffic** (`{slug}.knoxcall.com`).
The Management API (`/v1`) is unchanged.
Your plan's account-wide limit is a token bucket. It used to hold a full minute of your
per-minute rate, so you could send that whole minute's quota in one second. It now holds
**ten seconds** of it:
| Plan | Per minute (unchanged) | Most at once — was | Most at once — now |
| - | - | - | - |
| Free | 100 | 100 | 17 |
| Starter | 1,000 | 1,000 | 167 |
| Pro | 5,000 | 5,000 | 834 |
| Enterprise | 10,000 | 10,000 | 1,667 |
Your per-minute rate is unchanged: sent evenly, every request of it is still admitted. A
burst larger than the new bucket is refused with `429` and `X-RateLimit-Layer: tenant`
sooner than before. A per-tenant override that states its own bucket size keeps it.
On the wire:
* `X-RateLimit-Tenant-Limit`, `X-RateLimit-Limit` on a tenant `429`, the `limit` in that
`429`'s body and `limit=` in `X-Knox-RateLimit-Tenant` still report your **per-minute**
rate — the same numbers as before.
* **New:** `X-RateLimit-Tenant-Burst`, `X-RateLimit-Burst` on a tenant `429`, `burst` in
its body, and `burst=` appended at the end of `X-Knox-RateLimit-Tenant` state the bucket.
* `X-RateLimit-Tenant-Remaining` / `remaining=` is still what you can send right now, so it
now tops out at the bucket rather than at your per-minute rate.
* `X-RateLimit-Tenant-Reset`, `reset=` and `Retry-After` still mean "when the bucket is
full again", which is now at most about ten seconds away instead of a minute — so a
client that waits `Retry-After` after a tenant `429` waits seconds, not up to a minute.
See [Rate limiting](/advanced/rate-limiting) and [Response metadata](/api-reference/response-metadata).
**A change to existing behavior on proxied traffic**: your routes (in the cloud and on a
self-hosted proxy), the AI Gateway, the ephemeral proxy and inbound-webhook forwards. No
endpoint, field or error type is added or removed. `/v1/proxy` and `/v1/ai/*` forward fewer
of your request headers; every other `/v1` endpoint is unchanged.
A request reaches KnoxCall through an edge and a reverse proxy, which add headers
describing the connection to us. Some of them were being passed on to your upstream, and
so were two of KnoxCall's own. The headers below are no longer forwarded. Each is matched
by its **exact name**, in any letter case; there is no prefix rule.
| Group | Headers no longer forwarded |
| - | - |
| KnoxCall's own | `X-KnoxCall-Env` (an internal routing label our edge sets), `X-KnoxCall-Origin` (the marker the SDKs' route-aware interceptor sends), `X-KnoxCall-API-Key` (the API key header of KnoxCall's interactive demo; nothing outside the demo reads it) |
| The caller's address | `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IPv6`, `CF-Pseudo-IPv4` |
| Client certificate, from a proxy in front of KnoxCall | `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint`, `X-Client-Cert-Subject` |
| Cloudflare edge detail | `CF-Worker`, `CF-EW-Via`, `CF-Connecting-O2O` |
| Cloudflare visitor location | `CF-IPCity`, `CF-IPContinent`, `CF-IPLatitude`, `CF-IPLongitude`, `CF-Region`, `CF-Region-Code`, `CF-Metro-Code`, `CF-Postal-Code`, `CF-Timezone` |
| Cloudflare checks on the caller | `CF-Bot-Score`, `CF-Verified-Bot`, `CF-JA3-Hash`, `CF-JA4`, `Exposed-Credential-Check`, `Malicious-Uploads-Detection` |
| Cloudflare client certificate | `CF-Cert-Presented`, `CF-Cert-Verified`, `CF-Cert-Revoked`, `CF-Cert-Issuer-DN`, `CF-Cert-Subject-DN`, `CF-Cert-Issuer-DN-RFC2253`, `CF-Cert-Subject-DN-RFC2253`, `CF-Cert-Issuer-DN-Legacy`, `CF-Cert-Subject-DN-Legacy`, `CF-Cert-Serial`, `CF-Cert-Issuer-Serial`, `CF-Cert-SHA256`, `CF-Cert-SHA1`, `CF-Cert-Not-Before`, `CF-Cert-Not-After`, `CF-Cert-SKI`, `CF-Cert-Issuer-SKI` |
| Cloudflare Access | `Cf-Access-Jwt-Assertion` |
These were already withheld and still are: `X-Forwarded-For`, `X-Forwarded-Host`,
`X-Forwarded-Proto`, `X-Forwarded-Server`, `CF-Connecting-IP`, `CF-IPCountry`, `CF-Ray`,
`CF-Visitor`, `CDN-Loop`, `Cookie`, and the control headers `X-KnoxCall-Key`,
`X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-KnoxCall-Signature`, `X-KnoxCall-Agent-Id`,
`X-KnoxCall-Agent-Token` and `X-KnoxCall-Client-Assertion`. The complete list is now
published at [Request headers KnoxCall does not forward](/api-reference/request-headers).
**What to check.** If your caller, or a proxy of your own in front of a self-hosted
KnoxCall proxy, sends one of these names and your upstream reads it, it no longer arrives.
`X-Real-IP` and `True-Client-IP` are the likely ones; `X-Client-Cert-Verify`,
`X-Client-Cert-Thumbprint` and `X-Client-Cert-Subject` if you terminate mutual TLS
yourself. Nothing in the response says a header was withheld: the request is forwarded
without it.
**If your upstream needs one of them.** On a route, including the route behind an AI
Gateway agent, add the header to the route's injected headers. A header the route injects
is sent, with the value the route configures rather than the one your caller sent, so this
suits a fixed value: there is no template helper for the caller's IP address. The ephemeral
proxy and inbound-webhook forwards have no injected headers, so on those paths the header
cannot be sent under that name; send the value under a name that is not on the list.
**Still yours, still forwarded.** A header that only resembles one of these is not
affected. In particular the `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair and the
`cf-access-token` header, for an upstream behind Cloudflare Access, keep working.
**No response shape changed.** No endpoint, field or error type is added or removed. What changes
is the value in fields that already exist.
**`user_id` names the person behind a user-bound token.** An action taken with a token issued to a
person — the `knoxcall` CLI, the device grant, an authorization-code app — used to be recorded
with `user_id: null`, as if no one had done it. It is now recorded with that person's id. Actions
by an API key or a machine OAuth client still carry `user_id: null`. Entries written before this
change are not rewritten.
**Two new keys inside `details`** on entries made through `/v1`:
`actor_credential` (`{ "kind": "api_key" | "oauth_client", "id": "" }`) names the
credential that made the request, and `actor_user_source: "v1_bearer"` marks a `user_id` that
came from a user-bound token rather than a dashboard session. KnoxCall sets both from the
authenticated request; a request body cannot set or change them.
You will see this in `GET /v1/audit-logs`, `GET /v1/audit-logs/events`, `audit.event` webhook
payloads, SIEM exports and data-subject exports. If you filter the audit log by `user_id` to find
dashboard activity, add a check that `details.actor_user_source` is absent.
**Two behaviour changes for existing clients, one additive field.**
**`details` is now sanitised on `/v1`.** `GET /v1/audit-logs` and `GET /v1/audit-logs/events`
used to return `details` exactly as stored. They now apply the same sanitiser the dashboard
always has. Values under credential-shaped keys read `***REDACTED***`, and `user:password@`
userinfo is removed from any URL (older `webhook.created` entries could carry one).
Identifiers such as the acting `api_key_id` are kept. For an API key or OAuth client holding
`audit_log:list` this is the only change: you keep every other field of `details`.
**User-bound tokens get the dashboard's rule.** A token issued to a person (the `knoxcall` CLI,
device and authorization-code grants) sees `details` only if that person may: an Admin or Owner.
For a Viewer or Editor each row now carries `details: {}` and `details_redacted: true`. The rest
of the row is unchanged. The dashboard's audit detail view already applied this rule; this
change extends it to the list and `/v1` paths that did not.
**`details_redacted`** (boolean) is new on every row of both endpoints. It is additive: a
redacted `details` is always an empty object, never a string, so typed clients decode it
unchanged. The SDK types carry it as an optional field.
**`details` is always an object or `null`.** An entry whose stored `details` is not a JSON object
(a self-hosted agent can record a bare string, array or number) is now returned wrapped as
`{"value": ...}`, sanitised like any other. Previously it came back as stored, which typed clients
could fail to decode.
**`audit.event` webhooks.** Their payloads now carry sanitised `details`, in the delivery and in
your delivery log. Creating a webhook subscribed to `audit.event`, or making any change to one
that stays subscribed (a new URL, but also a rename or pausing it), needs what reading `details`
needs: `audit_log:list` on a key or OAuth client (every built-in key role that can manage webhooks
already has it), or an Admin or Owner behind a user-bound token. Otherwise the request is refused
with `403 forbidden`. Deleting one is not affected. An OAuth token narrowed by scope must also hold
a scope that reads the audit log (`audit-logs:read`), or it is refused with
`403 insufficient_scope`. Existing subscriptions keep delivering.
**A security fix that narrows what some people's CLI tokens can do, effective immediately.**
It applies to tokens issued to a *person* — `knoxcall login`, the device grant and apps you
authorize with the authorization-code flow. API keys and OAuth clients acting as themselves are
unchanged.
Such a token acts with its holder's role. For **Restricted (member)** and **Developer** members
those roles allowed, on `/v1`, operations the dashboard reserves for owners and admins. They
are now refused with **403 `forbidden`**:
* decrypting (`POST /v1/decrypt`, `POST /v1/crypto/keys/:name/decrypt`), rewrapping and signing
with transit keys, managing them, listing keys and reading a key's metadata
(`GET /v1/crypto/keys`, `GET /v1/crypto/keys/:name`);
* issuing PKI certificates and managing roots and roles;
* minting dynamic database credentials, revoking their leases, and managing connections and
roles;
* fetching a live OAuth2 access token (`GET /v1/secrets/:id/oauth2/token`);
* listing or creating API keys, and creating, changing or rotating OAuth clients;
* registering agents, and creating, changing or minting tokens for AI gateways (so
`knoxcall ai create` and `knoxcall ai mint` need an owner or admin).
The dashboard refuses the same person all of these but one: it shows transit keys and their
metadata to any member. On `/v1`, listing keys and reading a key's metadata need an owner or
admin, because `/v1` gates them on the key-management permission.
A **Read-only** member's token can no longer list API keys, mint or revoke wrap tokens
(`POST` / `DELETE /v1/wrap/tokens`), or report egress observations
(`POST /v1/wrap/egress-observations`): a person's token now also needs `secret:update` for the
first two and `route:create` for the third. API keys keep doing these on read alone.
**Owners and admins** keep what they had, except one thing: a token issued to a person can no
longer issue a client's mTLS certificate (`POST /v1/clients/:id/credentials` with
`mode: issue`), because the response carries the certificate's private key. Issue it in the
dashboard, register a certificate you generated yourself by its `sha256_thumbprint` (that still
works), or use an API key.
The rule behind that holds for every role: a token issued to a person never decrypts,
detokenizes, issues a certificate that comes back with its private key, mints a database
credential or fetches a live OAuth2 token. The owner and admin roles KnoxCall seeds never gave a
token those; a custom role that grants one explicitly is now refused too. Nor can such a token
hand one of those to a machine credential: attaching a role that carries one to a new API key
(`POST /v1/api-keys` with `role_ids`) is refused with **403 `privilege_escalation`**, and
rotating the secret of an OAuth client that holds one with **403 `forbidden`**. Those return
live credential material; do them in the dashboard where it offers them, or with an API key or
OAuth client an owner or admin grants the permission in the dashboard.
Nothing changes in the dashboard. If a script run with `knoxcall login` now gets a 403 for one
of these, ask an owner or admin for an API key that carries the permission it needs.
If the database is unavailable while KnoxCall checks a person's role for one of these, the
request now answers **503 `dependency_unavailable`** with `Retry-After` — retry it — rather than
a 403 that blamed the role.
**Breaking for narrowed OAuth tokens that create credentials.** A tenant narrows an access
token — say to `api-keys:write` or `oauth-clients:write` — so that whoever holds it can do
that one thing and nothing else. Four endpoints let such a token hand itself more:
* `POST /v1/api-keys` minted a key with no roles, which on the proxy can invoke every route
in its mode. A narrowed token now gets `403 privilege_escalation` and **mints no key at
all**, with or without `role_ids`.
* `POST /v1/agents` returned an agent credential, which the proxy also accepts for every
route. A narrowed token now gets `403 privilege_escalation`.
* `POST /v1/oauth-clients` and `PATCH /v1/oauth-clients/{id}` accepted any
`allowed_scopes` — `["*"]` included — and returned the new client's secret. From a
narrowed token, the client must now be one the token could itself hold: every scope listed
verbatim in the token's own scope, and `client_credentials` as the only grant type. An
update is judged on the client as it will be afterwards, so a narrowed token also cannot
re-enable or re-point a client that is already wider than it.
* `POST /v1/oauth-clients/{id}/rotate-secret` from a narrowed token is refused for a
client wider than the token, since rotation hands back that client's secret.
**Who is affected.** Only access tokens that were *deliberately narrowed*. API keys (`tk_`,
AKE), tokens minted with no scope (including every `knoxcall login` session and the
Terraform provider's default token), and tokens holding `*` are unaffected. To create
these credentials from automation, use an API key or an unnarrowed token.
**Also: `allowed_scopes` may no longer be set to a universal list.** `["*"]` and
`["*:*"]` mean "every scope", exactly as an empty list does, and an empty list has been
refused since 2026-08. Creating an OAuth client or a workload-identity binding with one, or
changing a client's list to one, now returns `400`. A client that **already** holds a
universal list keeps working and keeps minting tokens; re-sending the list it already has
(for example when saving its other settings) still succeeds. Replace `*` with the scopes
the client actually needs when you next edit it.
**One behaviour change for existing clients.** If you send `pan_egress_allowed_hosts` in a
request body to `POST /v1/routes`, `PATCH /v1/routes/:id` or
`PUT /v1/routes/:id/environments/:env`, you now get a **400** instead of having the field
silently ignored. A `null` or an empty array is still accepted as the no-op it is, so a client
that round-trips a config object unchanged is unaffected.
**What the field is.** A route may now carry its own list of destinations allowed to receive a
raw card number, for that route in that environment, on top of whatever your workspace-wide
allowlist permits. Before this, a tenant who needed to send a clear card number to one processor
had to open their whole workspace to that destination. The two lists are a union — a route grant
never narrows what the workspace already allows.
**It is deliberately not settable with an API key.** A credential that can proxy must not be able
to authorise itself a destination for raw card numbers, and the second-factor gate that protects
this setting cannot apply to a credential that has no user. Set it in the dashboard on the
route's **Security** tab, or with
`PUT /admin/pan-egress/routes/:routeId/environments/:env` as an owner or admin.
**`/v1` reads do not return the field**, so there is no read-then-write round trip that could
trip the 400 on a value you did not choose.
**It stores hosts, not a flag.** Editing a route's `target_base_url` after granting therefore
fails closed rather than carrying your consent to a destination you never approved.
**Additive, and Test data space only. Nothing changes in Live, and nothing changes for a
request that does not use one of these numbers.**
You could not exercise a failed payment on KnoxCall without a processor account and a real
decline. Test mode now has four documented card numbers that do it for you:
| Number | What happens when a request detokenizes it and sends |
| - | - |
| `4242 4242 4242 4242` | Forwarded to your destination like a real card |
| `4000 0000 0000 0002` | **Not forwarded** — `402 sandbox_test_card_declined` |
| `4000 0000 0000 3220` | **Not forwarded** — `402 sandbox_test_card_requires_action` |
| `4000 0000 0000 0069` | Forwarded, and tokenizes with `card_expires_on: "2020-01-31"` unless you supply your own `card_exp_*` |
They work on every server-side detokenize-and-send path: `POST /v1/proxy`, the `/wg` base-URL
gateway, your wrap subdomain, and a configured Route carrying a `detokenize` field action.
Tokenize them into a `pan` vault the way you tokenize any card.
**Two new error types, on the existing envelope.** No new response field and no new endpoint.
`sandbox_test_card_declined` and `sandbox_test_card_requires_action` both answer **402**, which
`plan_limit` also uses — branch on `error.type` (or the `X-Knox-Error` header), never on the
status alone. The response carries no `X-Knox-Destination-Status`, so a wrapped provider SDK
reads it as a KnoxCall error rather than as its provider's decline.
**It is a simulation, and it is our shape, not any processor's.** KnoxCall is a vault and a
proxy — it does not authorize or capture, so it cannot tell you what your processor would have
said. What it does is refuse to forward and say exactly why: nothing reaches your destination
at all on a decline or a 3-D Secure outcome.
Three things worth knowing:
* **Live is untouched.** All four are ordinary card numbers in the Live data space.
* **Only a value a vault released is recognised.** A test number pasted into a request body as
clear text is still refused by the raw-card-number egress control (`403
raw_pan_egress_refused`). Test cards are not a way around it.
* **A simulated decline still counts as a detokenize.** The card really was decrypted, so it
writes the usual `vault.detokenize` audit row, counts against your detokenize meter once,
and appears in the vault's Destinations view attributed to the host you named.
Browser capture of card numbers through hosted fields is unchanged and still switched off —
see [Elements → Card fields](/sdks/elements#card-fields) for the reason and when it opens. The
same table serves that path on the day it does; it is not a second implementation waiting to
be written.
**Additive, and honest about what it does not do yet.** Two new response fields on card
tokens, and **both are `null` on every token today.**
| Field | Shape |
| - | - |
| `card_funding_type` | `"credit"` / `"debit"` / `"prepaid"`, or `null` |
| `card_issuing_country` | ISO 3166-1 alpha-2, upper case (`"IE"`), or `null` |
They appear on the tokenize response, the bulk tokenize response and every row of
`GET /v1/vaults/{vault}/tokens`, beside `card_expires_on`. There is no new request field:
both are derived from the card's first six digits at tokenize time, never supplied by you.
**Why they are empty, in plain terms.** Naming a card's funding type and issuing bank from
its first six digits requires a commercially licensed BIN table. KnoxCall has not licensed
one, and whether we do is an open business decision. Everything around that table is built
and shipped — the storage, the lookup, the API shape, the SDK models — so the day we license
one the fields populate with no change to your integration. Until then they are `null`.
**So treat both as optional indefinitely.** They will also be `null` for every non-`pan`
vault, and for any card whose issuer a future table does not carry. Code that requires
either field to be present will break on cards that are perfectly valid.
`brand` and the last four digits are unaffected and have always worked: those come from the
*published* issuer ranges, which need no licence, and the `pan` token string itself preserves
them.
**Additive. Nothing changes for a request that does not ask for it.**
A card on file stops working when the card expires, and nothing on KnoxCall's side errors when
it does — we hold a token, not an issuing relationship. The first anyone hears about it is a
decline at your payment processor, often on a renewal. Card tokens can now carry the card's own
expiry, and KnoxCall tells you before that happens.
**1. Two new optional fields on tokenize.** `POST /v1/vaults/{vault}/tokens` and
`POST /v1/vaults/{vault}/tokens/bulk` (per value) accept:
| Field | Shape |
| - | - |
| `card_exp_month` | integer, 1–12 |
| `card_exp_year` | integer, **four digits** — `2029`, never `29` |
Both or neither, and **only on a vault whose `token_format` is `pan`**. A month without a year,
a two-digit year, or either field offered to a `generic` / `ssn` / `email` vault answers
`400 validation_error` — we refuse rather than quietly dropping it, because a quiet drop means
no notice ever fires and you never find out. In the bulk form the refusal names the offending
index and the whole batch is rolled back.
**This is not `ttl_seconds`.** `ttl_seconds` (and the `expires_at` you get back) is how long
*our token* lives. `card_exp_*` is when *the card* stops working. A token with no TTL at all can
belong to a card that dies in fourteen months.
**2. A new response field, `card_expires_on`.** The last day of the card's expiry month
(`"2029-07-31"` — a card is valid through the end of its month), on the tokenize response, the
bulk tokenize response and every row of `GET /v1/vaults/{vault}/tokens`. `null` for every
non-`pan` token and for any `pan` token whose tokenize call named no expiry.
**Tokens created before today carry `null`, and there is no backfill.** The expiry was never
captured, so it is not in the stored ciphertext and we cannot recover it. Those tokens receive no
expiry notice until the card is tokenized again. If you hold cards on file and want the notices,
re-tokenize on the next successful charge or on the customer's next update.
**3. A new webhook event, `vault.token.expiring`.** Subscribable like any other
(`GET /v1/webhooks/event-types` lists it), delivered **60 days and again 30 days** before
`card_expires_on`, at most once per token per threshold. It is scoped to the token's data space,
so a Test-mode card only ever reaches a Test-mode webhook.
```json theme={"dark"}
{
"event": "vault.token.expiring",
"timestamp": "2026-09-17T08:30:04.117Z",
"data": {
"token_id": "0f4a…",
"token": "4111114725124116",
"vault_id": "22c1…",
"vault_name": "cards",
"last4": "4116",
"card_expires_on": "2029-07-31",
"days_until_expiry": 60,
"threshold_days": 60,
"sandbox": false
}
}
```
`token` is the token you already store, not a card number — for a `pan` vault it preserves the
real BIN and last four, which is what makes it useful in a "your card ending 4116 expires soon"
message, and it is not chargeable anywhere. **The payload never carries the card number**: a
delivery whose contents look card-shaped is refused server-side rather than sent.
Each delivery is also written to your audit log as `vault.token.expiring` against the token's id,
so a workspace piping `audit.event` into a SIEM receives it without subscribing to anything new.
**The notice is a window, not a single day.** If the job that decides them misses a day, the
notice catches up on the next run rather than being skipped — so a "60 days" event may arrive a
few days late. It never arrives twice.
**SDKs.** `tokenize` / `tokenize_bulk` take the two parameters and the token models carry
`card_expires_on` in node, python, go, php and ruby. `constructEvent` needs no change: the event
list has always been open for forward compatibility, so an existing SDK build receives and parses
`vault.token.expiring` today.
**Behaviour change, and it can stop a step that worked yesterday.** On 2026-09-14 KnoxCall began
refusing a **payment card number sent in the clear** on `POST /v1/proxy`, on Routes, on the
workflow HTTP step, on webhook delivery and on `/v1/ai/*`, unless the destination host is on the
workspace's raw-card egress allowlist — which is **empty for every workspace by default**. That
refusal now covers the paths it did not reach:
| Where | What is scanned | What you see |
| - | - | - |
| **Send Email** step | subject, body, and every **attachment, decoded** — a card hidden in base64 is found | the step fails with the refusal message; nothing is sent |
| **Send SMS** step | the message text | the step fails; nothing is sent |
| **Database** step | every templated query **parameter**, and the SQL text | the step fails; **no connection is opened** |
| **Inbound webhook forwarding** | the provider's body, as relayed | your receiver is **not** called. The provider still gets `200` — a retry could not fix it — and the response body says `verified but not forwarded`, never `forward failed` |
| **`/v1/ai/*` MCP tool calls** | the tool call's **arguments** | JSON-RPC error `-32000` with the refusal message |
| **`/v1/ai/*` SDK requests** | the request body | the request is refused before the provider is dialled |
| **App connector steps** (catalog integrations) | the request body | the step fails; nothing is posted |
**A refused workflow step is NOT retried**, even when the node has retries configured: no retry can
change the outcome, and retrying would write one audit entry per attempt for one mistake. This is a
change to node retry behaviour generally — any permanent refusal now ends the retry loop on the
attempt that produced it. The step still **fails**, and the failure is still recorded.
**What is NOT affected.** Detection is unchanged: 13–19 digits (spaces and hyphens tolerated) that
pass the Luhn checksum **and** carry a recognised issuer prefix. An ordinary 16-digit order or
invoice number is not affected, and a `pan`-vault **token** is excluded by lookup rather than by
shape — tokens keep working exactly as before, which is the point.
**The remedy** is the same one on every surface: tokenize the card into a vault whose
`token_format` is `pan` and send `{{ token: … }}`, or — if the destination genuinely must receive
the number — have an owner or admin add its hostname with
`PUT /admin/pan-egress/allowed-hosts`. Adding a host requires a fresh second factor; removing one
does not. There is deliberately **no `/v1` twin** of that endpoint: an API key that can proxy
should not be able to authorise itself a destination for raw card numbers.
Each refusal writes an `egress.raw_pan_refused` audit entry carrying the destination host and a
masked sample (last four only, never the number) and increments a month-to-date counter readable at
`GET /admin/pan-egress/allowed-hosts`.
**Behaviour change for requests that detokenize a card number.** It affects you only if a
request resolves a token from a vault whose `token_format` is **`pan`** — on `POST /v1/proxy`
via `{{ token: ... }}`, or on a Route carrying a request-direction `detokenize` field action.
Requests that reference no `pan` token are byte-for-byte unchanged: same headers upstream,
same response, same logs.
**1. The upstream's response is now re-tokenized.** Detokenize-and-send puts the real card
number on the wire on purpose, and many processors echo it straight back — in the
confirmation object, in a `Location` redirect, in a plain-text receipt. Until now KnoxCall
returned that reply verbatim, so the number landed in your backend. Each occurrence of the
number **this request opened** is now replaced with the token it came from, before the
response reaches you — and before it reaches your outbound **webhooks**, the **workflow**
event a Route response triggers, the S3 **response archive**, and the `response_body` field
of the request log.
Scope is exact, not a card scan: only the values this request opened, only `pan` vaults, and
every spelling of the same number (`4111111111111111`, `4111 1111 1111 1111`,
`4111-1111-1111-1111`, and the form-encoded `4111+1111+1111+1111` /
`4111%201111%201111%201111`). A **different** card number your processor happens to mention,
and any value from a `generic`, `ssn` or `email` vault, are left alone. Replacement covers
JSON string fields at any depth, values sent as bare JSON numbers, object keys, form-encoded
and text bodies, and header values such as `Location` — a header is **replaced, not stripped**,
so a 3-D Secure redirect keeps working.
**2. `409 pan_streaming_unsupported` on `/v1/proxy`.** A request that both detokenizes a `pan`
token **and** asks for a streamed response is refused. A card number can straddle any chunk
boundary the upstream picks, so a streamed reply cannot be re-tokenized honestly, and the
combination is refused rather than served unprotected. **What to do:** send the card request
without streaming — no `Accept: text/event-stream` header and no `"stream": true` in the body
— or reference no `pan` token on the streamed call.
**3. `Accept-Encoding: identity` upstream, and `502 response_not_retokenizable`.** On these
requests KnoxCall now asks your upstream for an uncompressed reply, because a compressed body
cannot be scanned. If the upstream compresses anyway — or answers with a binary body — the
response is **withheld** rather than forwarded unscanned. **What to do:** have the upstream
honour `Accept-Encoding: identity` for this call, or move the card traffic to a Route (the
Route data plane decompresses `gzip`, `deflate` and `br` itself). **Do not blindly retry** —
the upstream call already happened and may already have charged the card.
**4. Routes: `502 relay_response_not_retokenizable`.** The Route equivalent, for a reply whose
body cannot be decoded for scanning — a content encoding KnoxCall does not implement, a binary
body, or one past the response-log ceiling. Same remedy, same warning about retrying. This is
a **refusal**, not a KnoxCall fault: it is recorded in your audit log as `route.action.refused`
and raises no operator alert.
**5. Audit.** Every re-tokenized response writes a `vault.retokenize` entry naming the vault
and how many occurrences were replaced — never the card number and never the token. A non-zero
count is worth acting on: it means your processor is echoing card data back to you. On
`/v1/proxy` the per-request `ephemeral_proxy.invoke` entry also carries
`retokenized_occurrences`.
Full detail: [Ephemeral Proxy — responses are re-tokenized](/essentials/proxy/ephemeral-proxy#responses-are-re-tokenized)
and [Vaults — a processor that echoes the card number back](/essentials/vaults/overview#a-processor-that-echoes-the-card-number-back).
**Behaviour change on every outbound path, and the one entry on this page most likely to
affect you today.** KnoxCall now inspects the body of an outbound request for a payment card
number in the clear. If it finds one, and the destination host is not on your account's
**raw-card egress allowlist**, the request is refused and **never leaves the platform**.
**The allowlist is empty for every existing account.** Nothing was migrated into it. So if you
are sending real card numbers through KnoxCall today — to a processor, to your own service, to
anything — those requests start receiving `403` the moment this ships, and keep receiving it
until you either tokenize the number or allow that host. Please read the remedy below before
you deploy against this.
**Where it applies.**
| Surface | Covered |
| - | - |
| `POST /v1/proxy` | Yes — both template mode (`{{ token: ... }}`) and transparent mode, and therefore `/wg` and your wrap subdomain |
| A configured Route | Yes — all transports: direct, mTLS, VPN, static egress |
| The workflow **HTTP Request** module | Yes — checked per dial, so a redirect re-send and a pagination body swap are each inspected, not just the first hop |
| The AI gateway | Yes — a card in a prompt, in `messages`, or in tool arguments. A failover candidate is **not** tried: sending the same card to a different provider is not a recovery |
| Outbound **webhooks** | Yes — the delivery is refused terminally and is not retried |
**What counts as a card number.** 13–19 digits with spaces and hyphens tolerated, that pass the
Luhn checksum **and** begin in a range payment cards are actually issued in. All three must
hold, so an ordinary 16-digit order or account reference is not affected. The check reads the
finalized outbound bytes *and* the parsed string values, so a card written as a JSON number, as
an object key, or with escaped digits is found too.
**What you get.** `403` with error type `raw_pan_egress_refused`. The message names the
destination and the remedy and contains **no digit of the card**. Each refusal writes an
`egress.raw_pan_refused` audit entry carrying the destination host and a masked sample (last
four only), and increments a month-to-date counter you can read back. Nothing is forwarded, and
no alert is raised on our side — this is your request being refused, not a KnoxCall fault.
**What to do — two options.**
1. **Tokenize the number and send the token.** Create a vault with `"token_format": "pan"` and
an `allowed_destinations` list, tokenize the card into it, and reference the token in your
request as `{{ token: }}`. KnoxCall substitutes the real number on the way out, to a
destination that vault allows, and re-tokenizes anything the processor echoes back (see the
2026-09-15 entry above). **This is the supported path and the reason vaults exist** — the
card never sits in your own logs, your own database, or ours.
2. **Allow the destination for raw card data.** An owner or admin can add the host with
`PUT /admin/pan-egress/allowed-hosts`, and read the current list and the refusal count with
`GET /admin/pan-egress/allowed-hosts`. The list is replace-the-whole-list, so two people
editing cannot silently union their changes. **Adding a host requires a fresh second factor;
removing one does not** — shutting the door stays reachable during an incident. Entries are
bare hostnames (`api.stripe.com`) or wildcard suffixes (`*.stripe.com`), the same pattern
syntax as a vault's `allowed_destinations`. Changes are audited as
`egress.pan_allowed_hosts_updated` with the previous list, so any change can be reverted from
the audit trail alone.
**What is unaffected.** A request carrying no card number takes no new code path and is
byte-for-byte unchanged. Detokenize-and-send keeps working: a value KnoxCall itself just opened
from a `pan` vault is exempt, because that vault already refused every host outside its own
`allowed_destinations` before anything was decrypted. On the AI gateway, an agent with
`pii_request_mode: 'tokenize'` is unaffected — the surrogate that mode mints is
format-preserving, and is recognised as something the tokenizer produced rather than something
you sent.
**What this does not cover yet.** The refusal is not on every outbound path in the platform.
MCP tool-call arguments and the SDK-level fetch wrapper on the AI gateway, inbound-webhook
forwarding, the workflow email and SMS modules, and templated database-query parameters are
**not** inspected today. A card number placed on one of those still leaves unscanned. Treat the
refusal as a safety net under the tokenization workflow, not as a substitute for it.
**Behaviour change on every tokenize endpoint.** A value that looks like a payment card
number is now refused by any vault whose `token_format` is not `pan`, with a new error
type, `wrong_format`, at `400`. The message names the format to use; it never echoes the
value.
| Endpoint | Before | Now |
| - | - | - |
| `POST /v1/vaults/{vault}/tokens` | `201` with a token | `400 wrong_format` |
| `POST /v1/vaults/{vault}/tokens/bulk` | tokens for the whole batch | `400 wrong_format`, naming the index; **nothing is stored for any value in the batch** |
| `POST /v1/client/tokenize` (hosted fields) | `400 tokenize_failed`, message stripped | `400 wrong_format` with the remedy |
| A Route `tokenize` field action | the value was tokenized | `422 relay_value_rejected` (request direction) / `502` (response direction) |
A `pan` vault is unchanged, and so is every value that is not card-shaped.
**Who this can break, and it is not only people storing cards.** A value is treated as a
card number when it is 13-19 digits after spaces and hyphens are removed, passes the Luhn
check, **and** begins in a range cards are issued in. A tenant whose generic vault holds
an identifier that happens to meet all three — an account or order reference in an
issued BIN range with a Luhn check digit — will start receiving `400 wrong_format` for
new values. Existing tokens are untouched: nothing is re-scanned, nothing expires, and
detokenize is unaffected.
**What to do.** If the value really is a card number, create a vault with
`"token_format": "pan"` and an `allowed_destinations` list, and tokenize into that. If it
is not, the leading digits are what tripped the check: an identifier your own system
mints can avoid the issued-BIN ranges, or you can drop the Luhn check digit. There is
deliberately **no opt-out flag** — a generic vault never holding a card number is what
keeps tenants who do not use the card format outside any boundary later drawn around
card data.
**Not a shape change.** `wrong_format` is a new value in the existing `error.type` field
on the existing `400` response; no request or response body changed shape, so no SDK
update is required.
**Breaking for narrowed OAuth tokens that create route field-actions.** A route action is a
**standing grant**: once stored, every request through that route runs it unattended, for as
long as the row exists, on the data plane — where the only scope checked is `routes:invoke`.
So `POST /v1/routes/{routeId}/actions` with `"action": "detokenize"` turns vault tokens back
into card numbers on every future request, and `"action": "decrypt"` opens `kc:` ciphertexts
the same way.
Until now both were satisfied by `routes:write`. Scope is derived from the request **path**,
and the path is the same for all four actions — so a tenant who minted a partner a token
narrowed to `routes:read routes:write` ("you may manage my routes") had also granted
"you may read the values behind my vault tokens, forever". Revoking the token did not remove
the action.
| Request body | Required before | Required now |
| - | - | - |
| `"action": "detokenize"` | `routes:write` | `routes:write` **and** `vaults:detokenize` (or `vaults:*`) |
| `"action": "decrypt"` | `routes:write` | `routes:write` **and** `transit:decrypt` (or `transit:*`) |
| `"action": "tokenize"` | `routes:write` | unchanged — `routes:write` |
| `"action": "encrypt"` | `routes:write` | unchanged — `routes:write` |
`encrypt` and `tokenize` are deliberately unchanged: both **close** data — they seal a field
to a key, or swap it for a token — and neither reads anything back.
**Who is affected.** Only access tokens that were deliberately narrowed. An API key (`tk_`,
`AKE`), and an OAuth client mirrored from one, carries no scope grant to narrow and is
unaffected; its policy rules are the control there and they have not changed.
**What you get.** `403 insufficient_scope`, with a `WWW-Authenticate` header naming the scope
to mint, and **nothing is written** — the refusal runs before the policy check and before any
vault or key is looked up.
**What to do.** Add `vaults:detokenize` and/or `transit:decrypt` to the client's
`allowed_scopes` and re-mint, exactly as for the
[2026-08-26](/api-reference/changelog) narrowings.
**Behaviour change for workflows started by an App Polling trigger — today the Slack
*New Channel Message* trigger.** If more new items appeared between two polls than one
poll may start workflow runs for, the extra items were **skipped and never fired**. They
are no longer skipped: they fire on the following poll.
A polling trigger starts at most **25 runs per poll**, and it remembers its place with a
cursor. When a poll found more than 25 new items it started the first 25 — and then moved
the cursor to the newest item it had *seen*, not the newest it had *started*. Everything
in between was behind the cursor on the next poll and was never picked up. There was no
error, no failed run and nothing in run history: the only sign was the missing runs.
The trigger now asks the provider for **at most as many items as one poll may start**,
so there is no un-started remainder for the cursor to move past. When the provider has
more waiting, the poll walks further within the same tick, up to a bounded number of
requests. So:
| In one polling interval | Before | Now |
| - | - | - |
| 25 or fewer new items | 1 run each | unchanged — 1 run each |
| 40 new items | 25 runs, **15 silently skipped** | 25 runs now, the other 15 on the next poll |
| 100 new items | 25 runs, **75 silently skipped** | 25 runs per poll until all 100 have run |
**What to expect.** A busy Slack channel that used to produce a steady 25 runs per minute
will now produce runs for the messages it was dropping, so **your workflow may execute more
often than it did last week** — that is the skipped work arriving, not a duplicate. Nothing
is run twice: each item still starts exactly one run. A large backlog drains at 25 items per
poll (one poll a minute by default), so a burst of a few hundred items takes a few minutes to
work through. Workflow executions are metered against your plan's execution allowance as
usual, and a backlog that exceeds it is deferred and retried rather than dropped.
**What to do.** Nothing. If a high-volume channel now starts more runs than you want,
narrow the trigger to the channel you care about or add a Condition step as the first node.
One residual we would rather state than leave you to find. A trigger whose provider returns
**newest first** — Slack's channel messages, Twilio's messages — reads back from the newest
item towards your cursor, and one poll only reaches so far back. An interval that produces
more items than a single poll reaches can still leave the OLDEST of them behind, because
the next poll starts from the newer cursor. How far each such trigger reaches is stated in
that trigger's own description in the workflow editor, and the platform logs the poll that
stopped short. Triggers that page **oldest first** — Notion, Airtable — have no such bound:
the remainder is always newer than the committed cursor and is picked up on the next poll.
**Breaking for narrowed OAuth tokens that call `/v1/proxy`.** The one-shot ephemeral proxy
is a single handler that answers **every** HTTP method, and a call to it resolves and
decrypts an escrowed upstream credential and spends it against the host you name. Until now
the required scope was derived from the verb, so `GET`, `HEAD` and `OPTIONS` were satisfied
by `proxy:read` and the remaining methods by `proxy:write`.
Neither is a read or a write of a resource. Every method now requires the same grant:
| Endpoint | Was | Now requires |
| - | - | - |
| `/v1/proxy` (any method) | `proxy:read` (GET/HEAD/OPTIONS), `proxy:write` (others) | `proxy:invoke` (or `proxy:*`) |
The proxy takes all of its inputs from headers — `X-Knox-Proxy-URL`,
`X-Knox-Upstream-Auth-Secret` — and needs no body, so a bodyless `GET /v1/proxy` did exactly
what a `POST` did. A token narrowed to `proxy:read` for an integration meant to observe was
a full credential-spending grant. `proxy:write` is excluded for the same reason
`secrets:write` and `vaults:write` are excluded above: escrowing a credential
(`POST /v1/wrap/credentials`) is not permission to spend it. This mirrors `routes:invoke` on
the route-configured data plane.
**Who is affected.** OAuth clients whose `allowed_scopes` is a narrowed list that calls
`/v1/proxy`. API keys, key-mirrored clients, `knoxcall login`, tokens granted `*:*`, and the
wrapped-SDK gateway (`/wg` base URLs minted by `POST /v1/wrap/tokens`, which authenticate
with the wrap token rather than an OAuth grant) are unchanged.
**What to do.** Add `proxy:invoke` to the client's `allowed_scopes` and re-mint. A refused
call returns `403 insufficient_scope` naming the scope in both the `WWW-Authenticate` header
and the body.
**Breaking for OAuth clients that omit `scope`.** `client_credentials` has always minted a
narrowed client's whole `allowed_scopes` list when the request named no `scope`. The
`authorization_code` and device grants did not: a request that named no scope produced a
token with an **empty** scope grant, and an empty grant is read as *unnarrowed* — it reached
every `/v1` endpoint, ignoring the client's `allowed_scopes` entirely.
All three grants now behave the same way:
| The request names | The client's `allowed_scopes` | The token now carries |
| - | - | - |
| no `scope` | `["routes:read"]` | `routes:read` |
| no `scope` | empty (API-key mirror, CLI client) | empty — unchanged, these inherit the key |
| `routes:read` | `["routes:read", "secrets:read"]` | `routes:read` — unchanged |
The same resolution runs when the token is minted, so an authorization code or device code
issued **before** the client's `allowed_scopes` was narrowed no longer mints the wider set.
If the narrowing leaves nothing the credential can carry, the exchange is refused with
`400 invalid_scope` rather than minting an empty (unnarrowed) grant; re-authorize to obtain
a code within the client's current list.
**Who is affected.** OAuth clients with an explicit `allowed_scopes` list whose integration
omits the `scope` parameter at `GET /oauth/authorize` or `POST /oauth/device_authorization`.
Those calls previously produced an unrestricted token; they now produce exactly what the
client is configured to allow. API keys, key-mirrored clients and `knoxcall login` (the CLI
client) carry no allow-list to resolve against and are unchanged.
**What to do.** Nothing, if the client's `allowed_scopes` already lists what your
integration uses. If a call starts returning `403 insufficient_scope`, the header and body
name the exact scope to add to the client and re-mint. The consent screen now lists those
permissions instead of "No specific permissions requested".
**Breaking for narrowed OAuth tokens.** Two `/v1` `GET` endpoints hand back decrypted
material rather than a description of it, and until now both were satisfied by the
ordinary read scope for their resource family. They now require a scope that names the
capability:
| Endpoint | Was | Now requires |
| - | - | - |
| `GET /v1/secrets/{id}/oauth2/token` | `secrets:read` | `secrets:oauth_token` (or `secrets:*`) |
| `GET /v1/vaults/{vault}/tokens/{token}` | `vaults:read` | `vaults:detokenize` (or `vaults:*`) |
The first returns a **live upstream OAuth2 access token**, refreshing it at the provider
when it is stale — so it mints a credential rather than reading one. The second returns the
**cleartext value** behind a vault token. Neither is a read of the resource, and granting
`secrets:read` ("Read secret metadata") or `vaults:read` ("Read vaults") was never intended
to carry them.
**Who is affected.** Only tokens that were *deliberately narrowed*. If you authenticate
with an API key (`tk_`/`AKE`), or with an OAuth client that KnoxCall mirrors from an API
key, nothing changes — those callers carry no scope grant to narrow. You are affected if
your OAuth client has an explicit `allowed_scopes` list, because a `client_credentials`
request that names no `scope` is minted with that whole list.
**What to do.** Add `secrets:oauth_token` (and/or `vaults:detokenize`) to the client's
`allowed_scopes`, then re-mint. A refused call returns `403 insufficient_scope` and names
the exact scope to request in both the `WWW-Authenticate` header and the response body:
```http theme={"dark"}
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="Requires secrets:oauth_token", scope="secrets:oauth_token"
```
Universal grants (`secrets:*`, `vaults:*`, `*:*`, `*`) continue to reach both endpoints.
**Unified error envelope + `X-Request-Id`.** Every failure on `/v1` now returns one
canonical shape — `{ "error": { "type", "message", "request_id" } }` — with a **bare-UUID**
`request_id` (no `req_` prefix). The same ID is now returned on **every** response, success
or error, in the new `X-Request-Id` header, so you can correlate a request even without
parsing the body. Added the `forbidden` type (403) for RBAC denials. See the new
[Errors reference](/api-reference/errors).
**Cross-worker rate-limit headers.** Management API rate limits are enforced per API key
across all workers. Responses now carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset` (epoch seconds) whenever a limit is configured — not only on `429`s —
plus `Retry-After` on a `429`. See the new [Rate limits reference](/api-reference/rate-limits).
**Dual idempotency-key header.** Mutating requests accept an idempotency key as either
`X-Idempotency-Key` or the standard `Idempotency-Key` spelling. A retry replays the stored
response with `X-Idempotent-Replay: true`; reuse with a different body returns `422`
(`idempotency_key_reuse`); a still-in-progress request returns `409`
(`request_in_progress`). See the new [Idempotency reference](/api-reference/idempotency).
**Typed OAuth2 / certificate secret creation in SDKs.** The first-party SDKs gained typed
helpers for creating OAuth2 and mTLS-certificate secrets, matching the dedicated
[create OAuth2 secret](/api-reference/secrets/create-oauth2) and
[create certificate secret](/api-reference/secrets/create-certificate) endpoints.
# Create Client
Source: https://docs.knoxcall.com/api-reference/clients/create
Creates a new authorized client
## Create Client
```text theme={"dark"}
POST /v1/clients
```
Creates a new authorized client.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the client |
| `ip_address` | string | Yes | IP address or CIDR range (e.g., `203.0.113.42` or `10.0.0.0/16`) |
| `type` | string | No | Client type: `user` or `server` (defaults to `server`) |
| `ip_notes` | object | No | Metadata about the IP address |
| `description` | string | No | Human-readable description |
### Response
Returns the created client row. Unlike [Get Client](./get), this response does **not** include the `route_assignments` array (route assignments only appear on `GET /v1/clients/:id`) and includes the raw `tenant_id` column.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const created = await client.clients.create({
name: "Production Backend",
ip_address: "203.0.113.0/24",
type: "server",
description: "Main backend servers"
});
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
created = client.clients.create(
name="Production Backend",
ip_address="203.0.113.0/24",
type="server",
description="Main backend servers"
)
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/clients \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Backend",
"ip_address": "203.0.113.0/24",
"type": "server",
"description": "Main backend servers"
}'
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields or invalid type |
| 403 | `plan_limit` | Client limit reached for your subscription plan |
| 409 | `conflict` | A client with this name already exists in this mode. Client names are unique per tenant per mode, so the same name may exist in both production and sandbox, but not twice within one |
# Client Credentials
Source: https://docs.knoxcall.com/api-reference/clients/credentials
List, add, update, and delete authentication credentials attached to a client
Each client can have one or more **credentials** — the actual authentication materials KnoxCall checks when a request arrives. Two credential kinds are available in v1:
| Kind | Description |
| - | - |
| `ip` | An IP address or CIDR range. Matched against the incoming source IP. |
| `mtls_thumbprint` | A client certificate SHA-256 thumbprint for mTLS authentication. KnoxCall can issue the certificate for you (`mode: "issue"`) or you can register an existing cert's thumbprint. |
***
## List Credentials
```text theme={"dark"}
GET /v1/clients/:id/credentials
```
Returns all credentials attached to a client.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "cred-uuid-1",
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"kind": "ip",
"label": "Primary office",
"data": { "value": "203.0.113.0/24", "notes": "" },
"enabled": true,
"expires_at": null,
"created_at": "2026-01-15T09:30:00.000Z",
"updated_at": "2026-01-15T09:30:00.000Z",
"last_matched_at": "2026-05-20T14:22:01.000Z"
}
],
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Client not found |
***
## Add Credential
```text theme={"dark"}
POST /v1/clients/:id/credentials
```
Attaches a new credential to a client.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `kind` | string | Yes | Credential type: `ip` or `mtls_thumbprint` |
| `label` | string | No | Human-readable label |
| `data` | object | Yes | Kind-specific data (see below) |
**For `kind: "ip"`:**
```json theme={"dark"}
{ "value": "203.0.113.42" }
```
`value` must be a valid IPv4/IPv6 address or CIDR range. The stored `data` object also carries a `notes` string (empty unless set via the client's `ip_address` dual-write path), so credential responses for `kind: "ip"` return `data` as `{ "value": ..., "notes": ... }`.
**For `kind: "mtls_thumbprint"` — issue a new certificate:**
```json theme={"dark"}
{ "mode": "issue" }
```
KnoxCall mints a certificate from your tenant CA, stores only the thumbprint, and returns the PEM bundle once in the `reveal` field. The private key is never stored.
Because the response carries a private key, issuing is a reveal. On this API it needs a machine
credential granted `client:create`: a token issued to a person (`knoxcall login`, a device or
authorization-code grant) is refused with `403`, whatever the person's role. In the dashboard
(a client's **Credentials** section, "Issue a new cert") only an **Owner** or an **Admin** can
issue, and each issue asks for a fresh second factor (an authenticator code, a passkey or a
security key) and writes a `plaintext.reveal` audit entry before the key is generated, the same
as issuing from a [certificate authority](/essentials/pki/pki-overview). Registering an existing
certificate's thumbprint (below) hands nothing back and needs neither.
**For `kind: "mtls_thumbprint"` — register an existing certificate:**
```json theme={"dark"}
{ "sha256_thumbprint": "a1b2c3...64-hex-chars" }
```
`sha256_thumbprint` must be a SHA-256 hex digest. Non-hex characters are stripped before validation, and the remaining string must be exactly 64 hex characters or the request fails with `400 validation_error` (`data.sha256_thumbprint must be a SHA-256 hex digest (64 hex chars).`).
### Response
```json theme={"dark"}
{
"data": {
"id": "cred-uuid-1",
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"kind": "ip",
"label": "Primary office",
"data": { "value": "203.0.113.0/24", "notes": "" },
"enabled": true,
"expires_at": null,
"created_at": "2026-01-15T09:30:00.000Z",
"updated_at": "2026-01-15T09:30:00.000Z",
"last_matched_at": null
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
For `mtls_thumbprint` with `mode: "issue"`, the response also includes a one-time `reveal` field:
```json theme={"dark"}
{
"data": {
"id": "cred-uuid-2",
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"kind": "mtls_thumbprint",
"data": { "sha256_thumbprint": "a1b2...", "subject": "CN=...", "serial": "...", "not_after": "2027-01-15T09:30:00Z" },
"enabled": true,
"expires_at": "2027-01-15T09:30:00Z",
"reveal": {
"certificate_pem": "-----BEGIN CERTIFICATE-----\n...",
"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...",
"ca_chain_pem": "-----BEGIN CERTIFICATE-----\n..."
}
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
The `reveal.private_key_pem` is returned only once. Store it immediately — it is not retrievable again.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing or invalid `kind`, or invalid `data` for the kind |
| 404 | `not_found` | Client not found |
***
## Update Credential
```text theme={"dark"}
PATCH /v1/clients/:id/credentials/:credId
```
Enables/disables a credential or updates its label.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
| `credId` | uuid | The credential ID |
### Request Body
At least one field is required.
| Field | Type | Description |
| - | - | - |
| `enabled` | boolean | Enable or disable this credential |
| `label` | string | Update the credential label |
### Response
Returns the updated credential object.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Neither `enabled` nor `label` provided |
| 404 | `not_found` | Credential not found |
***
## Delete Credential
```text theme={"dark"}
DELETE /v1/clients/:id/credentials/:credId
```
Removes a credential from a client. For mTLS thumbprint credentials, the underlying certificate is also revoked in the tenant CA's CRL.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
| `credId` | uuid | The credential ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Credential not found |
# Delete Client
Source: https://docs.knoxcall.com/api-reference/clients/delete
Permanently deletes a client and removes it from all route assignments
## Delete Client
```text theme={"dark"}
DELETE /v1/clients/:id
```
Permanently deletes a client and removes it from all route assignments.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Client not found |
# Get Client
Source: https://docs.knoxcall.com/api-reference/clients/get
Returns a single client with its route assignments
## Get Client
```text theme={"dark"}
GET /v1/clients/:id
```
Returns a single client with its route assignments.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Production Backend",
"type": "server",
"ip_address": "203.0.113.0/24",
"ip_notes": {},
"description": "Main backend servers",
"enabled": true,
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"updated_at": "2026-02-20T11:00:00.000Z",
"route_assignments": [
{
"route_id": "rt-uuid-1",
"route_name": "Payment Gateway",
"environment_name": "production"
},
{
"route_id": "rt-uuid-2",
"route_name": "User Service",
"environment_name": "production"
}
]
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Client not found |
# List Clients
Source: https://docs.knoxcall.com/api-reference/clients/list
Returns a paginated list of all clients in your tenant
## List Clients
```text theme={"dark"}
GET /v1/clients
```
Returns a paginated list of all clients in your tenant.
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
| `sort` | string | `created_at` | Sort by: `created_at`, `name`, `type`, or `ip_address` |
| `order` | string | `desc` | Sort direction: `asc` or `desc` |
| `type` | string | -- | Filter by client type: `user` or `server` |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Production Backend",
"type": "server",
"ip_address": "203.0.113.0/24",
"ip_notes": {},
"description": "Main backend servers",
"enabled": true,
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"updated_at": "2026-02-20T11:00:00.000Z"
}
],
"meta": {
"total": 8,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const page = await client.clients.list({ page: 1, per_page: 20 });
const clients = page.data;
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
page = client.clients.list(page=1, per_page=20)
clients = page["data"]
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl "https://api.knoxcall.com/v1/clients?type=server" \
-H "Authorization: Bearer $TOKEN"
```
# Update Client
Source: https://docs.knoxcall.com/api-reference/clients/update
Updates one or more fields on an existing client
## Update Client
```text theme={"dark"}
PATCH /v1/clients/:id
```
Updates one or more fields on an existing client.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The client ID |
### Request Body
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
| - | - | - |
| `name` | string | Display name |
| `type` | string | Client type: `user` or `server` |
| `ip_address` | string | IP address or CIDR range |
| `ip_notes` | object | IP metadata |
| `description` | string | Description |
| `enabled` | boolean | Enable or disable the client |
### Response
Returns the updated client object.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | No valid fields provided or invalid type |
| 404 | `not_found` | Client not found |
| 409 | `conflict` | Another client in this mode already uses this name. Client names are unique per tenant per mode (production and sandbox are separate) |
# Create Environment
Source: https://docs.knoxcall.com/api-reference/environments/create
Create a new environment for your tenant
## Create Environment
```text theme={"dark"}
POST /v1/environments
```
Creates a new environment.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Machine-friendly name. Must match `^[a-z0-9_-]+$` (lowercase alphanumeric, hyphens, underscores) |
| `display_name` | string | No | Human-friendly display name |
| `description` | string | No | Description of the environment |
| `color` | string | No | Hex color code for the UI (defaults to `#6366f1`) |
| `is_default` | boolean | No | Whether this is the default environment (defaults to `false`) |
### Response
Returns the created environment object.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const environment = await client.environments.create({
name: "staging",
display_name: "Staging",
description: "Pre-production testing",
color: "#f59e0b"
});
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
environment = client.environments.create(
name="staging",
display_name="Staging",
description="Pre-production testing",
color="#f59e0b"
)
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/environments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "staging",
"display_name": "Staging",
"description": "Pre-production testing",
"color": "#f59e0b"
}'
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Invalid name format or missing required field |
| 403 | `plan_limit` | Environments not available on your plan, or environment limit reached |
| 409 | `conflict` | An environment with this name already exists |
# Delete Environment
Source: https://docs.knoxcall.com/api-reference/environments/delete
Permanently delete an environment from your tenant
## Delete Environment
```text theme={"dark"}
DELETE /v1/environments/:id
```
Permanently deletes an environment. You cannot delete the default environment or an environment that is in use by route configurations.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The environment ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Cannot delete the default environment |
| 404 | `not_found` | Environment not found |
| 409 | `in_use` | Environment is used by one or more route configurations |
# List Environments
Source: https://docs.knoxcall.com/api-reference/environments/list
Returns all environments configured for your tenant
## List Environments
```text theme={"dark"}
GET /v1/environments
```
Returns all environments for your tenant (not paginated — returns the full list).
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "production",
"display_name": "Production",
"description": "Live production environment",
"color": "#22c55e",
"is_default": true,
"created_at": "2026-01-01T00:00:00.000Z"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "staging",
"display_name": "Staging",
"description": "Pre-production testing",
"color": "#f59e0b",
"is_default": false,
"created_at": "2026-01-15T10:00:00.000Z"
}
],
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const environments = await client.environments.list(); // bare array — not paginated
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
environments = client.environments.list() # bare list — not paginated
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/environments \
-H "Authorization: Bearer $TOKEN"
```
# Update Environment
Source: https://docs.knoxcall.com/api-reference/environments/update
Update one or more fields on an existing environment
## Update Environment
```text theme={"dark"}
PATCH /v1/environments/:id
```
Updates one or more fields on an existing environment.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The environment ID |
### Request Body
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
| - | - | - |
| `name` | string | Machine-friendly name (must match `^[a-z0-9_-]+$`) |
| `display_name` | string | Human-friendly display name |
| `description` | string | Description |
| `color` | string | Hex color code |
| `is_default` | boolean | Set as default environment |
### Response
Returns the updated environment object.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Invalid name format |
| 404 | `not_found` | Environment not found |
| 409 | `conflict` | An environment with this name already exists |
# Invoke Ephemeral Proxy
Source: https://docs.knoxcall.com/api-reference/ephemeral-proxy/invoke
One-shot proxy call that resolves vault tokens on the wire and forwards the request to an arbitrary HTTPS upstream.
The Ephemeral Proxy is a single endpoint — `ANY /v1/proxy` — that accepts any HTTP method and forwards the request to the URL you supply in the `X-Knox-Proxy-URL` header. References of the form `{{ token: ... }}` in the body or query string are resolved against your tenant's vaults before the request reaches the upstream.
For a conceptual overview see the [Ephemeral Proxy guide](/essentials/proxy/ephemeral-proxy).
Unlike the [Routes](/api-reference/routes) endpoints — which manage proxy *configuration* — this endpoint **executes** a one-shot proxy call. There is no `id` to look up, no row to list, and no environment overrides.
***
## Invoke
```text theme={"dark"}
ANY /v1/proxy
```
Forwards the request to the URL in `X-Knox-Proxy-URL`. Body and query-string `{{ token: ... }}` references are resolved against your tenant's vaults before the upstream call is made. The upstream response is streamed back unchanged.
### Request Headers
| Header | Required | Description |
| - | - | - |
| `Authorization` | Yes | `Bearer $TOKEN` — a minted `kc_` OAuth access token (see [Authentication](/api-reference/authentication)). Legacy `tk_` / `AKE` API keys are also accepted as Bearer on this endpoint. |
| `X-Knox-Proxy-URL` | Yes | The upstream URL. Must be `https://`, must use a DNS hostname (no IP literals), must pass SSRF validation. |
| `Content-Type` | No | Defaults to `application/json` if omitted. Non-JSON bodies are sent as-is for string content. |
| `X-Knox-Encrypted` | No | Base64-encoded JSON payload addressable from templates via `{{ encrypted \| json: $.path }}`. v1 is opaque base64-JSON; full JWE support is on the roadmap. |
| `X-Knox-Timeout-Ms` | No | Per-request upstream timeout. Positive integer ≤ `30000`. Defaults to `25000`. |
All other headers you send are forwarded to the upstream **except** for hop-by-hop headers (`Connection`, `Keep-Alive`, `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade`, `Proxy-Authorization`, `Proxy-Authenticate`), the `Authorization`, `Host` and `Content-Length` headers, `Cookie`, and any header beginning with `X-Knox-`.
Three more groups are never forwarded. Each name is matched exactly, in any letter case; apart from `X-Knox-` there is no prefix rule:
* **The control headers KnoxCall reads itself:** `X-KnoxCall-Key`, `X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-KnoxCall-Signature`, `X-KnoxCall-Agent-Id`, `X-KnoxCall-Agent-Token`, `X-KnoxCall-Client-Assertion`, `X-KnoxCall-Origin` and `X-KnoxCall-Env`.
* **The headers a reverse proxy adds to describe the connection to KnoxCall** rather than your request: `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Server`, `X-Real-IP`, `True-Client-IP`, `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint` and `X-Client-Cert-Subject`.
* **The request headers Cloudflare adds:** `CF-Connecting-IP`, `CF-Ray`, `CF-IPCountry` and the rest of the exact names in [Request headers KnoxCall does not forward](/api-reference/request-headers), which is the complete list.
A header that only resembles one of these is yours and is forwarded. That includes the `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair and the `cf-access-token` header for an upstream behind Cloudflare Access.
**Your KnoxCall credential is never forwarded.** `Authorization` and `Cookie` are always dropped, and `X-Api-Key` is dropped whenever it holds a KnoxCall credential (a value beginning `tk_`, `kc_`, `kr_` or `AKE`) — that is the key you authenticated *to KnoxCall* with, and sending it on would put a key granting your whole management API into your upstream's access logs.
An `X-Api-Key` carrying your **upstream's** own key — `sk-ant-…` for Anthropic, and so on — is forwarded unchanged, so providers that authenticate with this header keep working.
### Request Body
Optional. Sent to the upstream after token substitution. The body is scanned recursively for two template forms:
| Template | Resolves to |
| - | - |
| `{{ token: tok_xxx }}` | The raw value stored under token `tok_xxx` in any of your tenant's vaults. |
| `{{ encrypted \| json: $.path }}` | The value at JSONPath `$.path` inside the decoded `X-Knox-Encrypted` payload. |
| `\{{ ... }}` | An escape — renders a literal `{{ ... }}` and is never resolved. |
Anything else shaped like `{{ ... }}` is **rejected** with `400 template_error` rather than forwarded to the upstream. See [Templates](#templates).
Maximum **20** distinct token references per request.
### Response
The upstream's response is streamed back unchanged: same status code, same headers (minus hop-by-hop and `Content-Length`), same body bytes. KnoxCall adds two correlation headers:
| Response Header | Description |
| - | - |
| `X-Knox-Request-Id` | The KnoxCall request ID. Use it to look up the audit log entry. |
| `X-Knox-Destination-Status` | The upstream's status code, echoed in case an intermediate CDN rewrites it. |
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/proxy \
-H "Authorization: Bearer $TOKEN" \
-H "X-Knox-Proxy-URL: https://api.stripe.com/v1/charges" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "usd",
"source": "{{ token: tok_01HZX... }}"
}'
```
```python Python theme={"dark"}
import os
import requests
# KNOXCALL_ACCESS_TOKEN is a minted OAuth access token — see /api-reference/authentication
resp = requests.post(
"https://api.knoxcall.com/v1/proxy",
headers={
"Authorization": "Bearer " + os.environ["KNOXCALL_ACCESS_TOKEN"],
"X-Knox-Proxy-URL": "https://api.stripe.com/v1/charges",
"Content-Type": "application/json",
},
json={
"amount": 5000,
"currency": "usd",
"source": "{{ token: tok_01HZX... }}",
},
)
print(resp.status_code, resp.headers["X-Knox-Request-Id"])
print(resp.json())
```
```javascript Node.js theme={"dark"}
// KNOXCALL_ACCESS_TOKEN is a minted OAuth access token — see /api-reference/authentication
const resp = await fetch("https://api.knoxcall.com/v1/proxy", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`,
"X-Knox-Proxy-URL": "https://api.stripe.com/v1/charges",
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 5000,
currency: "usd",
source: "{{ token: tok_01HZX... }}",
}),
});
console.log(resp.status, resp.headers.get("X-Knox-Request-Id"));
console.log(await resp.json());
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing or malformed `X-Knox-Proxy-URL`, invalid `X-Knox-Timeout-Ms`, malformed `X-Knox-Encrypted`, or more than 20 token references. |
| 400 | `token_not_found` | A `{{ token: ... }}` reference does not exist in any of your tenant's vaults. |
| 400 | `token_expired` | A `{{ token: ... }}` reference names a token that exists but whose TTL has passed. Distinct from `token_not_found` so a lapsed TTL does not read as a token you never created. Nothing is substituted and nothing is sent upstream. |
| 400 | `template_error` | A template expression failed to render (malformed JSONPath, missing key in encrypted payload), **or** is not one of the supported forms — e.g. `{{ secret: x }}`, `{{ token: }}`, or a mis-cased `{{ TOKEN: ... }}`. Escape it as `\{{` if the braces are meant for the upstream. |
| 402 | `plan_limit` | Ephemeral Proxy requires the **Starter** plan or above. |
| 403 | `forbidden` | The upstream URL failed SSRF validation (private IP range, metadata endpoint, non-HTTPS, etc.). |
| 403 | `PolicyDenied` | RBAC denial — either the `ephemeral_proxy:invoke` gate or the per-token `vault:detokenize` gate denied the request. |
| 403 | `insufficient_scope` | The OAuth access token was deliberately narrowed and its grant does not include `proxy:invoke`. Applies to every HTTP method — see [Permissions](#permissions). |
| 409 | `pan_streaming_unsupported` | The request references a token from a **`pan`** vault *and* asks for a streamed response (`Accept: text/event-stream`, or `"stream": true` in the body). A streamed reply cannot be re-tokenized — a card number can straddle any chunk boundary the upstream picks — so the combination is refused rather than served unprotected. Send the card request without streaming. See [Responses are re-tokenized](/essentials/proxy/ephemeral-proxy#responses-are-re-tokenized). |
| 502 | `response_not_retokenizable` | The request detokenized a **`pan`** token and the upstream's reply could not be scanned for the card number — it came back compressed (KnoxCall sends `Accept-Encoding: identity` on these requests; this one was ignored) or binary. The reply was **withheld** rather than forwarded unscanned. **Do not blindly retry** — the upstream call already happened. |
| 500 | `internal_error` | A plan-limit lookup or token-resolution step failed internally — including a vault that cannot be opened (a key or KMS fault). The request is refused; the still-unresolved template is never forwarded. |
| 502 | `upstream_error` | The upstream did not respond, returned an unrecoverable transport error, or timed out. |
***
## Templates
### `{{ token: }}`
Substituted with the raw value of the matching vault token. Tokens are tenant-globally unique — you do not need to specify the vault.
```json theme={"dark"}
{
"card_number": "{{ token: tok_01HZX0M... }}",
"exp_month": 12
}
```
If the token doesn't exist in any of your tenant's vaults, the request fails with `400 token_not_found`. If the token exists but you lack `vault:detokenize` permission for the vault that owns it, the request fails with `403` (`PolicyDenied`). If it exists but has expired, the request fails with `400 token_expired`. In every refusal the template is left unresolved and the upstream is not called — a token reference that cannot be opened never travels as itself.
### `{{ encrypted | json: $.path }}`
Reads a value from the decoded `X-Knox-Encrypted` payload using JSONPath. Useful when you want to keep ad-hoc sensitive values out of your application's heap.
```bash theme={"dark"}
# Encode the payload your application would otherwise put directly in the body
PAYLOAD=$(echo -n '{"customer_id":"cus_123","cvc":"492"}' | base64)
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl -X POST https://api.knoxcall.com/v1/proxy \
-H "Authorization: Bearer $TOKEN" \
-H "X-Knox-Proxy-URL: https://api.stripe.com/v1/payment_methods" \
-H "X-Knox-Encrypted: $PAYLOAD" \
-H "Content-Type: application/json" \
-d '{
"customer": "{{ encrypted | json: $.customer_id }}",
"card": { "cvc": "{{ encrypted | json: $.cvc }}" }
}'
```
In v1 the payload is opaque base64-encoded JSON; full JWE with per-tenant frontend keypairs is on the roadmap.
### `\{{ ... }}` — sending literal braces
Any `{{ ... }}` that is not one of the two forms above is **rejected** with `400 template_error`. It is never forwarded. That is deliberate: silently passing an unrendered `{{ token: ... }}` through would disclose the token identifier to the third-party upstream and deliver a request with a hole where a credential should be.
When the braces are meant for the **upstream's** own templating, escape the opening delimiter with a backslash. The escape renders a literal `{{`, and the text after it is never treated as a template — so a single body can carry both:
```json theme={"dark"}
{
"card_number": "{{ token: tok_01HZX0M... }}",
"email_subject": "Hi \\{{ first_name }}, your receipt"
}
```
The upstream receives `card_number` substituted with the real value, and `email_subject` as the literal `Hi {{ first_name }}, your receipt`. Note the doubled backslash: JSON has no `\{` escape, so a literal backslash is written `\\` in the source.
Only the opening `{{` is escapable — a bare `}}` was never a delimiter and always passes through untouched, so nested JSON such as `{"a":{"b":{"c":1}}}}` is never rewritten. To bypass templating entirely and forward your bytes verbatim, send `X-Knox-Proxy-Mode: transparent` instead.
***
## Permissions
Each invocation evaluates two permissions:
| Resource | Action | When evaluated |
| - | - | - |
| `ephemeral_proxy` | `invoke` | Once per request, before URL validation. |
| `vault` | `detokenize` | Per token reference, scoped to the vault that owns the token. |
The `vault:detokenize` check is **enforced** per token reference, scoped to the vault that owns the token. A denial throws `PolicyDenied` and the request fails with `403`.
### OAuth scope
These evaluate what the **credential** may do. If you call with an OAuth access token whose client was deliberately narrowed, a separate check evaluates what that **token** may do, and it requires `proxy:invoke` (or `proxy:*`) — **on every HTTP method**, because one handler serves them all and a bodyless `GET` spends an escrowed credential exactly as a `POST` does. `proxy:read` and `proxy:write` do not satisfy it: escrowing a credential is not permission to spend it. A refused call returns `403 insufficient_scope`, naming the scope in the `WWW-Authenticate` header and the body. API keys and un-narrowed tokens carry no grant to enforce and are unaffected.
***
## Audit
Every invocation writes:
* An `api_requests` row with `proxy_mode = 'ephemeral'`, status code, latency, upstream host, source IP, and token count. The body, headers, and resolved token values are **never** stored.
* An `audit_logs` entry tagged `ephemeral_proxy.invoke` ([Audit Logs](/api-reference/audit-logs)).
* A Fleet Graph signal carrying method, upstream host, status code, and latency. The customer's path is recorded as `/proxy` — never the raw upstream path.
# Errors
Source: https://docs.knoxcall.com/api-reference/errors
The canonical KnoxCall error envelope, the full type-to-status table, and how to branch on error.type and correlate a request_id.
# Errors
Every failure on the KnoxCall Management API (`/v1`) returns **one canonical shape**. There
is a single `error` object with a machine-readable `type`, a human-readable `message`, and a
`request_id`. You branch on `type`; you show `message` to a human; you quote `request_id` to
support.
## The error envelope
```json theme={"dark"}
{
"error": {
"type": "not_found",
"message": "Route not found",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
| Field | Type | Description |
| - | - | - |
| `error.type` | string | Stable, machine-readable code. Branch on this — never on the HTTP status alone or on `message`. |
| `error.message` | string | Human-readable explanation. Safe to surface to your users; may change wording over time. |
| `error.request_id` | string (UUID) | The unique ID for this request. A **bare UUID** — never `req_`-prefixed. |
The `error` object is the only top-level key on a failure response — there is no `data` or
`meta` on an error. Success responses use `{ "data": ..., "meta": ... }`; failures use
`{ "error": ... }`. Check for the presence of `error` first.
## Type → status table
The HTTP status is derived from `error.type`. The mapping is stable — code against it.
| Error Type | HTTP Status | Meaning |
| - | - | - |
| `authentication_required` | 401 | No API key was provided in the request |
| `invalid_api_key` | 401 | The API key is invalid, revoked, or expired |
| `wrong_key_type` | 403 | A test key was used against production, or vice versa |
| `subscription_inactive` | 403 | The tenant's subscription is paused, cancelled, or past due |
| `forbidden` | 403 | The key is valid but lacks the role/scope required for this action (RBAC denial) |
| `validation_error` | 400 | The request body or query parameters failed validation |
| `not_found` | 404 | The requested resource does not exist (or is not visible to this key) |
| `method_not_allowed` | 405 | The request method is outside the set its group of endpoints supports, for example a `PATCH` to `/v1/api-keys/{id}`. The `Allow` response header lists the methods the group does support. Sent by `/v1/api-keys`, `/v1/audit-logs`, `/v1/logs`, `/v1/clients`, `/v1/environments`, `/v1/routes` and `/v1/webhooks`. No role or policy permission is checked for it, so it is the same for every credential |
| `conflict` | 409 | A resource with the same unique identifier already exists |
| `in_use` | 409 | The resource cannot be deleted because other resources depend on it |
| `plan_limit` | 402 / 403 | The tenant has reached a limit imposed by their current plan |
| `rate_limit_exceeded` | 429 | Too many requests — back off and retry (see [Rate limits](/api-reference/rate-limits)) |
| `internal_error` | 500 | An unexpected server error occurred — contact support with the `request_id` |
| `dependency_unavailable` | 503 | KnoxCall could not reach one of its own dependencies (`error.dependency` is `control_plane` or `rate_limit_store`) in time, so the request was refused rather than served from stale data or unmetered. Carries `Retry-After` (also echoed as `error.retry_after`, whole seconds). Safe to retry after that wait; **not** a credential problem — never re-authenticate on it. Answered on the data plane and the AI plane too, with the [response block](/api-reference/response-metadata) |
| `sandbox_test_card_declined` | 402 | **Test data space only.** The request released a documented KnoxCall [test card](/essentials/vaults/overview#test-cards) that simulates a decline. Nothing was forwarded |
| `sandbox_test_card_requires_action` | 402 | **Test data space only.** The request released the test card that simulates a 3-D Secure challenge. Nothing was forwarded |
### Idempotency error types
Sent only on mutating requests that carry an idempotency key. See
[Idempotency](/api-reference/idempotency) for the full flow.
| Error Type | HTTP Status | Meaning |
| - | - | - |
| `invalid_idempotency_key` | 400 | The `X-Idempotency-Key` value is malformed or too long |
| `request_in_progress` | 409 | A request with the same key is still being processed — retry shortly |
| `idempotency_key_reuse` | 422 | The same key was reused with a **different** request body |
`422` is used **only** for `idempotency_key_reuse`. Ordinary input-validation failures are
always `400 validation_error` — never `422`.
## Branching on `error.type`
Match on `error.type`, not on HTTP status codes and not on the human `message`. Statuses are
shared across several types (three different types map to `403`), and `message` wording is
not part of the contract.
```typescript Node.js theme={"dark"}
// OAuth access token minted via POST /oauth/token — see /api-reference/authentication
const resp = await fetch("https://api.knoxcall.com/v1/routes/does-not-exist", {
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
if (!resp.ok) {
const { error } = await resp.json();
switch (error.type) {
case "not_found":
// handle a missing resource
break;
case "rate_limit_exceeded":
// read Retry-After and back off
break;
case "dependency_unavailable":
// KnoxCall could not serve it: wait Retry-After, then resend — the request never ran
break;
case "invalid_api_key":
case "authentication_required":
// re-authenticate
break;
default:
console.error(`KnoxCall error ${error.type}: ${error.message} (request ${error.request_id})`);
}
}
```
```python Python theme={"dark"}
# token: an OAuth access token minted via POST /oauth/token — see /api-reference/authentication
resp = requests.get(
"https://api.knoxcall.com/v1/routes/does-not-exist",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code >= 400:
error = resp.json()["error"]
if error["type"] == "not_found":
... # handle a missing resource
elif error["type"] == "rate_limit_exceeded":
... # read Retry-After and back off
elif error["type"] == "dependency_unavailable":
... # KnoxCall could not serve it: wait Retry-After, then resend
else:
raise RuntimeError(
f"KnoxCall {error['type']}: {error['message']} "
f"(request {error['request_id']})"
)
```
The first-party [SDKs](/sdks/overview) already map these types to typed exceptions
(`NotFoundError`, `RateLimitError`, `AuthenticationError`, and so on) so you can `catch`
them directly instead of inspecting raw responses.
## Correlating a request — `request_id` and `X-Request-Id`
Every response carries the same request ID in two places:
* **`error.request_id`** in the JSON body (on failures) and **`meta.request_id`** (on
successes).
* The **`X-Request-Id`** response header — present on **every** response, success or error.
```http theme={"dark"}
HTTP/1.1 404 Not Found
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"error": {
"type": "not_found",
"message": "Route not found",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
The header lets you record the ID from your HTTP client even when you never parsed the body
(for example on a `500` you logged and moved past). The two values are always identical.
When you contact support about a failed request, **quote the `request_id`**. It lets us
trace the exact request through our systems — the upstream call, the tenant, the timing —
without you having to reconstruct what happened.
## What's Next?
Headers, `Retry-After`, and backoff guidance for `429` responses.
Safe retries for mutating requests, replay semantics, and `409` / `422` behavior.
Base URLs, response envelope, pagination, and quick examples.
OAuth 2.1 + DPoP, CLI login, and API key types.
# Idempotency
Source: https://docs.knoxcall.com/api-reference/idempotency
Retry mutating KnoxCall API requests safely with an idempotency key — replay semantics, and the 409 / 422 rules that keep retries from creating duplicates.
# Idempotency
Network calls fail in the worst way: the request reaches the server, the response is lost, and
your client has no idea whether the operation happened. Retrying blindly risks creating the
resource twice. An **idempotency key** makes a retry safe — KnoxCall recognises the repeated
key and replays the original outcome instead of performing the operation again.
Send an idempotency key on any **mutating** request (`POST`, `PUT`, `PATCH`, `DELETE`). It is
ignored on safe `GET` requests, which are naturally idempotent.
## Sending an idempotency key
Send a unique key per logical operation. A UUID is a good default.
| Header | Notes |
| - | - |
| `X-Idempotency-Key` | The canonical KnoxCall spelling. |
| `Idempotency-Key` | The standard spelling is **also accepted** — use whichever your HTTP client makes convenient. |
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: 3f8a9b2c-1d4e-4f6a-8b0c-2e1f0a3b4c5d" \
-d '{ "name": "STRIPE_API_KEY", "value": "sk_live_..." }'
```
Generate a **fresh** key for each distinct operation, and reuse the **same** key only when
retrying that exact operation. Reusing one key for two genuinely different requests is an
error — see [Reuse with a different body](#reuse-with-a-different-body-422).
## Replaying a stored response
The first request with a given key is processed normally and its response is stored. Any later
request with the **same key and the same body** does not re-run the operation — KnoxCall
replays the stored status code and body, and adds a header so you can tell it was a replay:
```http theme={"dark"}
HTTP/1.1 201 Created
X-Idempotent-Replay: true
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY" },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
`X-Idempotent-Replay: true` means "you are seeing a stored result, not a fresh one." Its
absence means the request executed for real. The replayed body is byte-for-byte the original
response, including its original status code.
## Error semantics
Idempotency errors use the canonical [error envelope](/api-reference/errors).
### Still in progress → `409`
If a request with the same key is **still being processed** when a retry arrives, the retry is
rejected with `409` and the `request_in_progress` type. Wait briefly and retry — the original
is on its way to completion.
```json theme={"dark"}
{
"error": {
"type": "request_in_progress",
"message": "A request with this idempotency key is still being processed.",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Reuse with a different body → `422`
If you reuse a key that was already used for a **different** request body, KnoxCall rejects the
new request with `422` and the `idempotency_key_reuse` type rather than silently doing the
wrong thing. This is the API's only `422`. The fix is to use a fresh key for the new operation.
```json theme={"dark"}
{
"error": {
"type": "idempotency_key_reuse",
"message": "This idempotency key was already used with a different request body.",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Malformed key → `400`
A key that is malformed or too long is rejected with `400` and the `invalid_idempotency_key`
type before any work is done.
### Summary
| Condition | Status | Error type | What to do |
| - | - | - | - |
| Same key, same body, original finished | 200 / 201 / … | — | You get the replayed response with `X-Idempotent-Replay: true` |
| Same key, same body, original still running | 409 | `request_in_progress` | Wait briefly and retry |
| Same key, **different** body | 422 | `idempotency_key_reuse` | Use a fresh key for the new operation |
| Malformed / oversized key | 400 | `invalid_idempotency_key` | Send a well-formed key (a UUID works) |
## Recommended pattern
1. Generate one key per logical operation (a UUID) and hold onto it for the duration of your
retries.
2. Send it as `X-Idempotency-Key` on the mutating request.
3. On a network error, timeout, or `5xx`, **retry with the same key** — you will either
perform the operation once or replay its stored result.
4. On `409 request_in_progress`, wait briefly and retry the same key.
5. Never reuse a key for a different operation; if the body must change, mint a new key.
The first-party [SDKs](/sdks/overview) attach an idempotency key to mutating requests
automatically and reuse it across their internal retries, so safe retries work out of the box.
## What's Next?
The canonical error envelope and the full type → status table.
Headers, `Retry-After`, and backoff guidance for `429` responses.
Base URLs, response envelope, pagination, and quick examples.
OAuth 2.1 + DPoP, CLI login, and API key types.
# Cancel Migration
Source: https://docs.knoxcall.com/api-reference/migrations/cancel
Cancel an in-flight migration, flipping its status to cancelled.
## POST /admin/migrations/:id/cancel
Cancel an in-flight migration. Only valid for migrations in `pending`, `discovering`, or `awaiting_review` status.
**Auth**: `requireOwnerOrAdmin`
Cancellation flips the migration's `status` to `cancelled` (setting `completed_at`) and records a `migration.cancel` critical audit entry. It does **not** automatically tear down the discovery lease or any provisioned DB proxy routes — remove those separately via the `DELETE /admin/migrations/:id/db-proxy-routes/:routeId` endpoint.
### Response
```json theme={"dark"}
{ "id": "...", "status": "cancelled" }
```
Returns `404` if the migration is not found or its status is anything other than `pending`, `discovering`, or `awaiting_review` (for example `committing`, `failed`, `completed`, or already `cancelled`).
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations/550e8400.../cancel \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
# Commit Migration
Source: https://docs.knoxcall.com/api-reference/migrations/commit
Write approved migration items into live KnoxCall secrets, one all-or-nothing batch per request.
## POST /admin/migrations/:id/commit
Write `approved` items into live KnoxCall secrets. Each item creates or updates a secret and one `secret_environment_config` row per target environment. After commit the item's encrypted value is zeroed and its status becomes `committed`.
**Large migrations commit in batches.** One request commits at most `batch_size` items (250) in a single transaction. If `remaining_count` in the response is greater than 0, the migration is left in `awaiting_review` and you issue the same request again to commit the next batch; already-committed items are never re-attempted, because the batch is selected by item status. An unbounded commit would exceed the 100-second edge timeout on large migrations, leaving the caller with a `524` and no report of what was written.
**Auth**: `migration_reviewer` permission + **5-minute step-up**
**Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`.
The migration must be in `awaiting_review` status. If any item in a batch fails to write, that **whole batch** is rolled back and the migration reverts to `awaiting_review` so the operator can investigate and retry. Batches committed by earlier requests are durable and are not rolled back.
A `migration.commit` entry is written to `audit_log_critical` after each successful batch, carrying `committed_count`, `remaining_count` and `batch_size`.
### Response
```json theme={"dark"}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"committed_count": 30,
"remaining_count": 0,
"batch_size": 250
}
```
`status` is `completed` only when `remaining_count` is `0`. While items remain it is `awaiting_review`:
```json theme={"dark"}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "awaiting_review",
"committed_count": 250,
"remaining_count": 2750,
"batch_size": 250
}
```
Returns `409` if the migration is not in `awaiting_review` status, or if there are no approved items to commit.
```bash theme={"dark"}
# /admin/* lives on the dashboard host (admin.knoxcall.com / any
# knoxcall.com host), gated by your admin session JWT + X-Tenant-ID.
# This route also requires a recent (within 5 min) second-factor
# step-up with action_scope "secret.migration" — perform one on the same
# session first (POST /auth/passkey/verify-step-up, /auth/2fa/verify-step-up,
# or /auth/2fa/webauthn/verify-step-up for a security key), or this call
# returns 403 with requires_step_up: true, action_scope "secret.migration"
# and allowed_methods ["passkey", "2fa"]. An emailed code does not count, and
# a verification made for any other action does not satisfy this gate.
curl -X POST https://admin.knoxcall.com/admin/migrations/550e8400.../commit \
-H "Authorization: Bearer $KC_SESSION_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
# Create Migration
Source: https://docs.knoxcall.com/api-reference/migrations/create
Create a new migration / discovery job against an external secret store.
## POST /admin/migrations
Create a new migration / discovery job. KnoxCall runs a live probe against the provider before persisting — if the federation grant is broken, the request fails immediately with `400` and nothing is stored.
**Auth**: `requireOwnerOrAdmin` — no step-up required.
### Request body
| Field | Required | Description |
| - | - | - |
| `provider` | ✅ | `aws_sm` \| `aws_ssm` \| `azure_kv` \| `gcp_sm` |
| `mode` | ✅ | `pull` (server-side pull), `capture` (agent-side intercept), or `both` |
| `credentials` | ✅ | Provider-specific federation parameters (no static keys — see below) |
| `scope` | — | Provider-specific scope constraints (regions, vaults, etc.) |
**`credentials` by provider:**
| Provider | Required fields |
| - | - |
| `aws_sm` / `aws_ssm` | `role_arn` (IAM role ARN), optionally `external_id` |
| `azure_kv` | `tenant_id` (Azure AD GUID), `client_id` (app registration GUID) — uses WIF, no client\_secret |
| `gcp_sm` | `project_number`, `workload_identity_pool`, `workload_identity_provider`, `service_account_email` |
Static credential fields (`access_key_id`, `secret_access_key`, `client_secret`, `private_key`, `service_account_json`, etc.) are **rejected at the API level** with `400`.
### Federation subject
KnoxCall signs a short-lived OIDC assertion that your cloud provider validates against
its trust policy. The `sub` claim is **specific to your tenant**:
```
knoxcall-migration:
```
Configure that exact value — Azure's federated-credential **Subject**, or a GCP WIF
**attribute condition** such as
`attribute.sub == "knoxcall-migration:"`. The New Migration page
shows the correct string for the tenant you are signed into.
Earlier connections were told to trust the bare constant `knoxcall-migration`, which was
identical for every KnoxCall customer — any KnoxCall tenant's assertion satisfied a trust
policy naming it. Existing migrations keep working on the old subject, but update your
trust policy to the tenant-specific value. The assertion also carries a
`knoxcall_tenant_id` claim you can gate on immediately if you would rather add an
attribute condition than re-cut the subject.
**`scope` by provider:**
| Provider | Optional scope fields |
| - | - |
| `aws_sm` / `aws_ssm` | `regions` (array of region strings), `arn_allowlist` |
| `azure_kv` | `subscription_id` (GUID), `vault_names` (array) — one of these is required |
| `gcp_sm` | `project_ids` (array of GCP project ID strings) |
```bash AWS Secrets Manager theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/migrations \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws_sm",
"mode": "pull",
"credentials": {
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallMigrationReader",
"external_id": "550e8400-e29b-41d4-a716-446655440000"
},
"scope": {
"regions": ["us-east-1", "eu-west-1"]
}
}'
```
```bash Azure Key Vault theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/migrations \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "azure_kv",
"mode": "pull",
"credentials": {
"tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh",
"client_id": "11112222-3333-4444-5555-666677778888"
},
"scope": {
"vault_names": ["my-prod-vault", "my-staging-vault"]
}
}'
```
```bash GCP Secret Manager theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/migrations \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "gcp_sm",
"mode": "pull",
"credentials": {
"project_number": "123456789012",
"workload_identity_pool": "projects/123456789012/locations/global/workloadIdentityPools/knoxcall-pool",
"workload_identity_provider": "projects/123456789012/locations/global/workloadIdentityPools/knoxcall-pool/providers/knoxcall-provider",
"service_account_email": "migration-reader@my-project.iam.gserviceaccount.com"
},
"scope": {
"project_ids": ["my-gcp-project"]
}
}'
```
### Response
```json theme={"dark"}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"note": "Migration enqueued. Poll GET /admin/migrations/:id for discovery progress."
}
```
# DB Proxy Routes
Source: https://docs.knoxcall.com/api-reference/migrations/db-proxy-routes
Create, list, and delete wire-protocol proxy tunnels for migrated database credentials.
## DB Proxy Routes
DB proxy routes are wire-protocol tunnels that let agents connect to a migrated database through KnoxCall using a `localhost:` listener. They can be created for any migration the reviewer owns — the `committed_secret_id` must reference an existing tenant secret, but no particular migration status is required. Routes are removed only via `DELETE /admin/migrations/:id/db-proxy-routes/:routeId`; cancelling the migration does not delete them.
The write endpoints (`POST` and `DELETE`) require `migration_reviewer` permission + **5-minute step-up**. The `GET` (list) endpoint requires only tenant access to the migration — no `migration_reviewer` role or step-up.
***
## POST /admin/migrations/:id/db-proxy-routes
Create a new wire-protocol proxy route for a migration.
**Auth**: `migration_reviewer` + **5-minute step-up**
### Request body
| Field | Required | Description |
| - | - | - |
| `protocol` | ✅ | Wire protocol: `postgres`, `mysql`, `mongodb`, `redis`, `sqlserver` |
| `upstream_host` | ✅ | Hostname or IP of the real database |
| `upstream_port` | ✅ | Port of the real database |
| `committed_secret_id` | ✅ | ID of the committed KnoxCall secret holding the database credentials |
| `credential_username` | ✅ | Database username to authenticate with. Required for every protocol **except** `redis` |
| `local_port` | conditional | Agent-local port the tunnel listens on (must be unique per agent). Required when `bind_mode` is `tcp`; must be **1024–65535** (privileged ports under 1024 are not supported). Ignored — and stored as `null` — for the `unix` and `pipe` modes, which have no TCP port |
| `bind_mode` | — | How the agent listener is exposed: `tcp` (default), `unix`, or `pipe` |
| `unix_socket_path` | conditional | Absolute path (must start with `/`) for the listener. Required when `bind_mode` is `unix` |
| `pipe_name` | conditional | Simple named-pipe name (no backslashes) for the listener. Required when `bind_mode` is `pipe` |
| `upstream_database` | — | Database name to connect to |
| `upstream_username` | — | Stored upstream username for display/reference |
| `tls_mode` | — | `prefer` (default), `require`, `disable` |
| `agent_id` | — | Scope the tunnel to a specific agent UUID; omit for any agent |
| `allowed_uids` | — | Array of Unix UIDs allowed to use the tunnel |
| `secret_migration_item_id` | — | Associate the route with a specific migration item |
Each route binds exactly one listener, the one its `bind_mode` names: the other two listener fields are stored as `null`.
Returns `409` if that listener — `local_port` for `tcp`, `unix_socket_path` for `unix`, `pipe_name` for `pipe` — is already in use by another enabled route for the same agent. The three are separate namespaces: a port never conflicts with a socket path.
### Response
```json theme={"dark"}
{ "id": "route-uuid" }
```
Status `201 Created`.
***
## GET /admin/migrations/:id/db-proxy-routes
List all proxy routes for a migration.
**Auth**: tenant access to the migration (no `migration_reviewer` role or step-up required)
### Response
```json theme={"dark"}
{
"routes": [
{
"id": "route-uuid",
"protocol": "postgres",
"upstream_host": "prod-db.internal",
"upstream_port": 5432,
"upstream_database": "myapp",
"upstream_username": "readonly",
"tls_mode": "prefer",
"local_port": 15432,
"agent_id": null,
"allowed_uids": [],
"committed_secret_id": "secret-uuid",
"secret_migration_item_id": "item-uuid",
"enabled": true,
"created_at": "2026-05-25T10:00:00Z",
"updated_at": "2026-05-25T10:00:00Z"
}
]
}
```
***
## DELETE /admin/migrations/:id/db-proxy-routes/:routeId
Remove a proxy route. The agent tunnel is torn down immediately.
**Auth**: `migration_reviewer` + **5-minute step-up**
### Response
```json theme={"dark"}
{ "id": "route-uuid", "deleted": true }
```
Returns `404` if the route is not found for this migration and tenant.
# Get Migration
Source: https://docs.knoxcall.com/api-reference/migrations/get
Retrieve full details for a single migration, including a sample of its discovered items.
## GET /admin/migrations/:id
Retrieve full details for a single migration, including a sample of its discovered items.
### Response
```json theme={"dark"}
{
"migration": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"provider": "aws_sm",
"mode": "pull",
"status": "awaiting_review",
"region": "us-east-1",
"scope": { "regions": ["us-east-1"] },
"discovered_count": 42,
"approved_count": 30,
"rejected_count": 2,
"committed_count": 0,
"expired_count": 0,
"denylisted_count": 1,
"error_details": null,
"created_at": "2026-05-20T09:00:00Z",
"started_at": "2026-05-20T09:00:05Z",
"completed_at": null
},
"items": [
{
"id": "item-uuid",
"source_provider": "aws_sm",
"source_id": "arn:aws:secretsmanager:us-east-1:111122223333:secret:stripe/prod-abc123",
"source_path": "stripe/prod",
"source_region": "us-east-1",
"source_version": null,
"source_metadata": {},
"bucket_classification": "a_https",
"bucket_classification_source": "heuristic",
"target_environments": ["production"],
"status": "pending_review",
"denylist_reason": null,
"captured_at": "2026-05-20T09:00:30Z"
}
]
}
```
`bucket_classification` is `"a_https"` (HTTPS API secret) or `"b_wire"` (database credential). `bucket_classification_source` is `"heuristic"` (auto-detected) or `"user_override"` (reviewer changed it).
Items are returned most-recent-first, limited to 200. For full pagination use the items sub-resource (coming in a future API version).
# Approve Migration Item
Source: https://docs.knoxcall.com/api-reference/migrations/items-approve
Approve a migration item for import, optionally overriding target environments or bucket classification.
## POST /admin/migrations/:id/items/:itemId/approve
Approve a migration item for import. Optionally override the target environments or bucket classification at approval time.
**Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)**
**Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`.
Step-up is enforced server-side: the platform checks for a recent re-authentication by the signed-in user. No step-up token or header is sent on this request. To create a verification first, the user re-authenticates with a second factor, passing `"action_scope": "secret.migration"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. If no recent verification exists, this endpoint returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "secret.migration", "allowed_methods": ["passkey", "2fa"] }`.
### Request body (all optional)
| Field | Type | Description |
| - | - | - |
| `target_environments` | string\[] | Override which environments to import into. Must not name the same environment twice — each environment is a single destination, so a repeat could only overwrite what the first mention wrote. |
| `bucket_override` | `"a_https"` \| `"b_wire"` | Override the auto-detected bucket classification |
### Response
```json theme={"dark"}
{ "id": "item-uuid", "status": "approved" }
```
Returns `409` if the item is not in `pending_review` status.
Returns `400` with `{ "error": "target_environments contains duplicate environment names" }` if `target_environments` names the same environment more than once.
# Reject Migration Item
Source: https://docs.knoxcall.com/api-reference/migrations/items-reject
Reject a migration item and immediately zero its encrypted value.
## POST /admin/migrations/:id/items/:itemId/reject
Reject a migration item. The item's encrypted value is immediately zeroed.
**Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)**
**Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`.
Step-up is enforced server-side: the request carries no step-up token or header. The signed-in user must have completed a second-factor step-up within the last 5 minutes, passing `"action_scope": "secret.migration"` in the body: an authenticator-app code via `POST /auth/2fa/verify-step-up`, a passkey via `POST /auth/passkey/verify-step-up`, or a security key (or passkey) via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`. An emailed code does not satisfy this gate. If no recent verification exists, this endpoint returns `403 { "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "secret.migration", "allowed_methods": ["passkey", "2fa"] }`.
### Request body
| Field | Required | Description |
| - | - | - |
| `reason` | ✅ | Non-empty string (max 500 characters) explaining the rejection |
### Response
```json theme={"dark"}
{ "id": "item-uuid", "status": "rejected" }
```
Returns `409` if the item is not in `pending_review` status.
# Reveal Item Value
Source: https://docs.knoxcall.com/api-reference/migrations/items-reveal
Reveal the plaintext or obfuscated value of a discovered migration item.
## GET /admin/migrations/:id/items/:itemId/value
Reveal the plaintext (or obfuscated) value of a discovered migration item.
**Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)** made with a second factor and `"action_scope": "secret.migration"` (an authenticator-app code via `POST /auth/2fa/verify-step-up`, a passkey via `POST /auth/passkey/verify-step-up`, or a security key via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`). An emailed code does not count, and a verification made for another action does not satisfy this gate; a missing one answers `403` with `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`.
**Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`.
By default returns an obfuscated value (`first4••••last4` format). Append `?full=1` to receive the full plaintext — this is logged separately in the critical audit chain.
Every call to this endpoint writes a `migration.item.reveal` entry to `audit_log_critical` (throwing — the reveal is refused if the audit write fails). The tenant owner receives an email notification when the reviewer is a different user.
### Response
```json theme={"dark"}
{
"id": "item-uuid",
"value": "sk_live_••••••••••••••••••••••••••••••••Xz4q",
"value_length": 51,
"value_sha256_prefix": "a3f8c2e1b9d7f4a1",
"mode": "obfuscated"
}
```
With `?full=1`:
```json theme={"dark"}
{
"id": "item-uuid",
"value": "sk_live_EXAMPLE_AbCdEfGhIjKlXz4q",
"value_length": 32,
"value_sha256_prefix": "a3f8c2e1b9d7f4a1",
"mode": "full"
}
```
# List Migrations
Source: https://docs.knoxcall.com/api-reference/migrations/list
List all secret store migration jobs for the tenant (most recent 100).
## GET /admin/migrations
List all migrations for the tenant.
Returns the most recent 100 migrations for the tenant. No query parameters are supported.
### Response
```json theme={"dark"}
{
"migrations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"provider": "aws_sm",
"mode": "pull",
"status": "awaiting_review",
"region": "us-east-1",
"discovered_count": 42,
"approved_count": 30,
"rejected_count": 2,
"committed_count": 0,
"expired_count": 0,
"denylisted_count": 1,
"error_details": null,
"created_at": "2026-05-20T09:00:00Z",
"started_at": "2026-05-20T09:00:05Z",
"completed_at": null,
"created_by": "user-uuid"
}
]
}
```
```bash theme={"dark"}
curl "https://admin.knoxcall.com/admin/migrations" \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
# Verification Events
Source: https://docs.knoxcall.com/api-reference/migrations/verification-events
List verification probe events for a migration, and manually mark a migration or item as verified clean.
## GET /admin/migrations/:id/verification-events
Returns verification probe events for a migration (most-recent first, up to 500), plus the current safe-to-delete state from the migration row.
**No pagination** — the response is capped at 500 events.
### Response
```json theme={"dark"}
{
"migration_id": "550e8400-e29b-41d4-a716-446655440000",
"safe_to_delete": false,
"verified_at": null,
"verification_window_days": 30,
"events": [
{
"id": "evt-uuid",
"secret_migration_item_id": "item-uuid",
"event_type": "operator_confirmed_clean",
"endpoint_url": null,
"outcome": "clean",
"details": { "confirmed_by": "user-uuid", "notes": null },
"source": "manual",
"created_at": "2026-05-25T10:25:00Z"
}
]
}
```
| Field | Description |
| - | - |
| `safe_to_delete` | `true` once the operator has confirmed the migration clean at the migration level |
| `verified_at` | Timestamp of the first migration-level confirmation, or `null` |
| `verification_window_days` | Days the system waits before allowing source-store deletion |
| `events[].event_type` | e.g. `operator_confirmed_clean`, or system probe event types |
| `events[].outcome` | `clean` or probe result |
| `events[].source` | `manual` (operator) or `auto` (system probe) |
***
## POST /admin/migrations/:id/verification-events
Manually mark a migration (or a single migration item) as verified clean. Requires `migration_reviewer` permission + **5-minute step-up**.
When called without `secret_migration_item_id`, marks the entire migration as `safe_to_delete = true` and sets `verified_at` if not already set.
### Request body
| Field | Required | Description |
| - | - | - |
| `secret_migration_item_id` | — | If provided, the confirmation is scoped to a single item. If omitted, confirms the entire migration. |
| `notes` | — | Optional operator note (stored in `details.notes`) |
### Response
```json theme={"dark"}
{ "id": "evt-uuid" }
```
Status `201 Created`.
Returns `403` if the caller lacks `migration_reviewer`. Returns `404` if the migration is not found.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/migrations/550e8400.../verification-events \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{ "notes": "All secrets validated in production — safe to remove from AWS SM." }'
```
# API Overview
Source: https://docs.knoxcall.com/api-reference/overview
Learn how to authenticate and interact with the KnoxCall Management API
# API Overview
## Introduction
The KnoxCall REST API gives you full programmatic control over your API gateway configuration. You can manage routes, secrets, clients, environments, webhooks, and API keys -- everything available in the dashboard, accessible through a clean, predictable interface.
All API endpoints return JSON and follow consistent conventions for authentication, pagination, error handling, and response structure.
This API is for **managing** your KnoxCall configuration. To call the routes you've configured, send traffic to your tenant proxy URL (e.g., `https://{your-slug}.knoxcall.com/{your-route}`, or `https://sandbox-{your-slug}.knoxcall.com/{your-route}` in sandbox).
## Base URLs
```text theme={"dark"}
https://api.knoxcall.com/v1
```
Use live credentials — a minted `kc_live_` OAuth token, or a production API key (`tk_` / `AKE` prefixed).
```text theme={"dark"}
https://sandbox.knoxcall.com/v1
```
Use test credentials (`kc_test_` tokens or `tk_test_` keys) only. Data is isolated from production.
Sandbox and production environments are completely isolated. API keys, routes, secrets, and all other resources created in one environment are not accessible from the other.
## Authentication
Every request to the KnoxCall API must be authenticated. For new integrations we recommend one of the [first-party SDKs](/sdks/overview) — they handle OAuth 2.1 token minting, caching, refresh, and DPoP for you:
```typescript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const routes = await client.routes.list();
```
Calling the REST API directly, mint a short-lived OAuth 2.1 access token and pass it as a Bearer token. The curl examples on this page reuse `$TOKEN` from this mint:
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN"
```
Long-lived API keys remain fully supported as a legacy alternative, passed in either header:
| Method | Header | Example |
| - | - | - |
| Bearer token | `Authorization` | `Authorization: Bearer tk_live_abc123...` |
| API key header | `x-api-key` | `x-api-key: tk_live_abc123...` |
API keys are scoped per tenant and can be created in the [KnoxCall Dashboard](https://knoxcall.com) or via the [API Keys endpoint](/api-reference/api-keys).
For creating an OAuth client, OAuth grant types, CLI login (`knoxcall login`), workload identity federation, key types, and security best practices, see the [Authentication guide](/api-reference/authentication).
## Response Format
Every response from the KnoxCall API follows a consistent JSON structure.
### Successful Response (Single Resource)
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Payment Gateway",
"target_base_url": "https://api.stripe.com/v1",
"enabled": true,
"created_at": "2026-01-15T09:30:00Z"
},
"meta": {
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Successful Response (List)
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Payment Gateway",
"enabled": true
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "User Service",
"enabled": true
}
],
"meta": {
"total": 100,
"page": 1,
"per_page": 20,
"total_pages": 5,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Error Response
Every error on `/v1` returns the same canonical envelope — a single `error` object with a machine-readable `type`, a human-readable `message`, and a bare-UUID `request_id`:
```json theme={"dark"}
{
"error": {
"type": "not_found",
"message": "Route not found",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
The `request_id` is a bare UUID (for example `550e8400-e29b-41d4-a716-446655440000`) — it is **not** prefixed with `req_`. The same value is returned on **every** response (success or error) in the `X-Request-Id` header, so you can correlate a log line even when you never parsed the body. Quote it when contacting support and we can trace the exact request through our systems.
For the complete list of error types, how to branch on `error.type`, and correlation guidance, see the [Errors reference](/api-reference/errors).
## Pagination
List endpoints support pagination via query parameters:
| Parameter | Default | Max | Description |
| - | - | - | - |
| `page` | `1` | `floor(100000 / per_page) + 1` | Page number to retrieve |
| `per_page` | `20` | `100` | Number of items per page |
| `sort` | `created_at` | -- | Field to sort by |
| `order` | `desc` | -- | Sort order: `asc` or `desc` |
```bash theme={"dark"}
curl "https://api.knoxcall.com/v1/routes?page=2&per_page=50&sort=name&order=asc" \
-H "Authorization: Bearer $TOKEN"
```
The response `meta` object contains the total count and page information so you can build pagination controls:
```json theme={"dark"}
{
"meta": {
"total": 237,
"page": 2,
"per_page": 50,
"total_pages": 5,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Depth limit
Pagination is offset-based, and the server will not skip more than **100,000
rows**. `page` is clamped to `floor(100000 / per_page) + 1` — 1,001 pages at the
maximum `per_page` of 100 — and `total_pages` is capped at the same value, so a
loop that walks until `page >= total_pages` always terminates.
`meta.page` is the **effective** page and always matches the rows you got back;
if you ask for a deeper one you receive the last reachable page, not an error.
`meta.total` keeps reporting the true row count, so when `total` is larger than
`total_pages × per_page` you are past the depth limit. Narrow the result set with
a filter — a date range, a status, a resource id — rather than paging further.
## Rate Limiting
API requests are rate-limited **per API key**, enforced consistently across all API workers. When you exceed the limit you receive a `429` response with the `rate_limit_exceeded` error type.
Rate-limit headers are returned on **every** response for which a limit is configured — not only on `429`s — so you can track your remaining budget as you go:
| Header | Description |
| - | - |
| `X-RateLimit-Limit` | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Epoch seconds (UTC) at which the current window resets |
| `Retry-After` | Seconds to wait before retrying — sent **only** on `429` responses |
If you receive a `429` response, back off and retry after the number of seconds indicated by the `Retry-After` header. Continuing to send requests during a rate limit will not reset the window.
See the [Rate limits reference](/api-reference/rate-limits) for backoff guidance and worked examples.
## Error Types
All errors return a consistent structure with a `type` field that you can use for programmatic error handling.
| Error Type | HTTP Status | Description |
| - | - | - |
| `authentication_required` | 401 | No API key was provided in the request |
| `invalid_api_key` | 401 | The API key is invalid, revoked, or expired |
| `wrong_key_type` | 403 | A test key was used against production, or vice versa |
| `subscription_inactive` | 403 | The tenant's subscription is paused, cancelled, or past due |
| `forbidden` | 403 | The key is valid but lacks the role/scope required for this action (RBAC denial) |
| `rate_limit_exceeded` | 429 | Too many requests -- back off and retry |
| `validation_error` | 400 | The request body or parameters failed validation |
| `not_found` | 404 | The requested resource does not exist |
| `conflict` | 409 | A resource with the same unique identifier already exists |
| `in_use` | 409 | The resource cannot be deleted because other resources depend on it |
| `plan_limit` | 402 / 403 | The tenant has reached a limit imposed by their current plan |
| `internal_error` | 500 | An unexpected server error occurred -- contact support |
For `validation_error` responses, the `message` field contains a human-readable description of which field failed validation and why. Validation failures are always `400` — the only `422` on the API is `idempotency_key_reuse` (see [Idempotency](/api-reference/idempotency)).
Mutating requests can also return idempotency errors — `invalid_idempotency_key` (400), `request_in_progress` (409), and `idempotency_key_reuse` (422). The complete type→status table, including these, lives in the [Errors reference](/api-reference/errors).
## Quick Examples
### List all routes
```bash cURL theme={"dark"}
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
resp = requests.get(
"https://api.knoxcall.com/v1/routes",
headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"}
)
routes = resp.json()["data"]
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/routes", {
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
const { data: routes } = await resp.json();
```
### Create a secret
```bash cURL theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "STRIPE_API_KEY",
"value": "sk_live_..."
}'
```
```python Python theme={"dark"}
import os
import requests
resp = requests.post(
"https://api.knoxcall.com/v1/secrets",
headers={
"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}",
"Content-Type": "application/json"
},
json={
"name": "STRIPE_API_KEY",
"value": "sk_live_..."
}
)
secret = resp.json()["data"]
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/secrets", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "STRIPE_API_KEY",
value: "sk_live_..."
})
});
const { data: secret } = await resp.json();
```
### Delete a client
```bash cURL theme={"dark"}
curl -X DELETE https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TOKEN"
```
```python Python theme={"dark"}
import os
import requests
resp = requests.delete(
"https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"}
)
result = resp.json()["data"] # {"deleted": true}
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` }
});
const { data } = await resp.json(); // { deleted: true }
```
## What's Next?
OAuth 2.1 + DPoP, CLI login, API key types, and security best practices.
Create and manage your API proxy routes.
Store and inject sensitive credentials into your routes.
Control who can access your routes with API clients.
# Rate Limits
Source: https://docs.knoxcall.com/api-reference/rate-limits
How the Management API rate-limits per API key, the response headers it returns, and how to back off correctly on a 429.
# Rate Limits
Requests to the KnoxCall Management API (`/v1`) are rate-limited **per API key**. The limit is
enforced consistently across every API worker, so a burst spread across parallel connections
counts against the same budget — there is no per-process loophole.
This page covers the **Management API** (`api.knoxcall.com/v1`) — the control plane you use
to configure routes, secrets, clients, and so on. Rate limits on your **proxy traffic**
(the routes you expose to your own callers) are configured separately, per route / per
client / per tenant — see [Rate Limiting overview](/essentials/rate-limiting/overview).
## Rate-limit headers
When a limit is configured for a request, the response includes these headers — on **every**
response, not only on a `429`, so you can watch your remaining budget as you go:
| Header | Type | Description |
| - | - | - |
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window |
| `X-RateLimit-Reset` | integer | Epoch seconds (UTC) at which the current window resets and `Remaining` returns to `Limit` |
| `Retry-After` | integer | Seconds to wait before retrying — sent on a `429` response, and on a `503 dependency_unavailable` (KnoxCall could not reach one of its own dependencies; see [Errors](/api-reference/errors)). Never on a success |
A normal, well-under-budget response looks like this:
```http theme={"dark"}
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 597
X-RateLimit-Reset: 1754390400
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
```
`X-RateLimit-Reset` is an **epoch-seconds** timestamp. Convert it to a delay with
`reset - now()` (in seconds) if you want to sleep until the window rolls over rather than
retrying blindly.
## The 429 response
When you exceed the limit, the request is rejected with HTTP `429` and the canonical
[error envelope](/api-reference/errors) using the `rate_limit_exceeded` type. The response
also carries a `Retry-After` header telling you exactly how long to wait:
```http theme={"dark"}
HTTP/1.1 429 Too Many Requests
Retry-After: 4
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1754390400
X-Request-Id: 7b0f2c9a-1d3e-4a5b-9c8d-2e1f0a3b4c5d
Content-Type: application/json
{
"error": {
"type": "rate_limit_exceeded",
"message": "Rate limit exceeded. Retry after 4 seconds.",
"request_id": "7b0f2c9a-1d3e-4a5b-9c8d-2e1f0a3b4c5d"
}
}
```
Continuing to send requests while rate-limited does **not** reset the window — it just
burns effort. Wait for `Retry-After` seconds (or until `X-RateLimit-Reset`) before your
next attempt.
## Backoff guidance
1. **Respect `Retry-After` first.** On a `429` it is the authoritative wait — sleep that many
seconds before retrying.
2. **Watch `X-RateLimit-Remaining` proactively.** If it is trending toward `0`, slow down
before you get a `429` rather than after.
3. **Add jitter.** When several workers hit the limit together, a fixed `Retry-After` makes
them all retry in lockstep. Add a small random offset (e.g. `Retry-After + random(0, 1s)`)
to spread the retries.
4. **Cap your retries.** Use exponential backoff with a ceiling and a maximum attempt count;
surface a clear error to the caller rather than retrying forever.
```typescript Node.js theme={"dark"}
async function withRetry(fn, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const resp = await fn();
if (resp.status !== 429) return resp;
if (attempt >= maxAttempts) return resp; // give up, let the caller handle it
const retryAfter = Number(resp.headers.get("Retry-After") ?? 1);
const jitter = Math.random(); // 0–1s
await new Promise((r) => setTimeout(r, (retryAfter + jitter) * 1000));
}
}
```
```python Python theme={"dark"}
import random, time
def with_retry(request, max_attempts=5):
for attempt in range(1, max_attempts + 1):
resp = request()
if resp.status_code != 429:
return resp
if attempt == max_attempts:
return resp # give up, let the caller handle it
retry_after = int(resp.headers.get("Retry-After", "1"))
time.sleep(retry_after + random.random()) # + jitter
```
The first-party [SDKs](/sdks/overview) implement `Retry-After`-aware backoff with jitter for
you and raise a typed `RateLimitError` once retries are exhausted — you rarely need to hand-
roll the loop above.
## What's Next?
The canonical error envelope and the full type → status table.
Retry mutating requests safely without creating duplicates.
Per-route / per-client / per-tenant limits on your own proxy traffic.
Base URLs, response envelope, pagination, and quick examples.
# Request Headers KnoxCall Does Not Forward
Source: https://docs.knoxcall.com/api-reference/request-headers
The exact request headers KnoxCall withholds from your upstream on proxied traffic, and what to do if your upstream needs one of them
# Request headers KnoxCall does not forward
When KnoxCall proxies a request for you, a fixed set of request headers is withheld
from your upstream. They describe the connection between your caller and KnoxCall,
or they are instructions and credentials meant for KnoxCall itself, so they stop
here.
This page is the complete list of those headers. It applies to proxied traffic on
every path:
* your **routes**, in the cloud and on a self-hosted proxy
* the **AI Gateway** (`/v1/ai/...`)
* the **ephemeral proxy** (`/v1/proxy`)
* **inbound-webhook forwards** (`hooks.knoxcall.com`)
The match is on the **exact header name**, in any letter case. There is no prefix
rule behind this list: a header that merely starts with `CF-`, `X-Forwarded-` or
`X-KnoxCall-` and is not named below is not withheld by it.
This page covers which headers are withheld. It is not a full description of how
each path builds the request it sends to your upstream; that is on each path's own
page.
## The list
### Headers that address KnoxCall
These tell KnoxCall who is calling and which route to use. KnoxCall reads them and
does not pass them on.
| Header | What it is |
| - | - |
| `X-KnoxCall-Key` | Your KnoxCall API key |
| `X-KnoxCall-API-Key` | The API key header of KnoxCall's interactive demo |
| `X-KnoxCall-Route` | The route to call |
| `X-KnoxCall-Environment` | The environment to use |
| `X-KnoxCall-Signature` | The request signature, on a route that requires signed requests |
| `X-KnoxCall-Agent-Id` | Agent credentials |
| `X-KnoxCall-Agent-Token` | Agent credentials |
| `X-KnoxCall-Client-Assertion` | A signed assertion KnoxCall's own services use to identify themselves |
| `X-KnoxCall-Origin` | The marker the SDKs' route-aware interceptor sends |
| `X-KnoxCall-Env` | An internal routing label KnoxCall's edge sets |
| `Cookie` | The cookies a browser attached. On a KnoxCall host these include a signed-in KnoxCall session |
### Headers a proxy in front of KnoxCall adds
A reverse proxy or CDN adds these to say where the request came from. They describe
your caller's connection to KnoxCall, not the request your upstream should see.
| Header | What it is |
| - | - |
| `X-Forwarded-For` | The caller's address |
| `X-Forwarded-Host` | The host the caller used |
| `X-Forwarded-Proto` | The scheme the caller used |
| `X-Forwarded-Server` | The proxy's own name |
| `X-Real-IP` | The caller's address |
| `True-Client-IP` | The caller's address |
| `X-Client-Cert-Verify` | Result of mutual-TLS client verification |
| `X-Client-Cert-Thumbprint` | The client certificate's thumbprint |
| `X-Client-Cert-Subject` | The client certificate's subject |
### Headers Cloudflare adds
KnoxCall's cloud edge runs on Cloudflare. These are the request headers Cloudflare
itself can add on the way in.
| Header | What it is |
| - | - |
| `CF-Connecting-IP` | The caller's address |
| `CF-Connecting-IPv6` | The caller's address |
| `CF-Pseudo-IPv4` | The caller's address |
| `CF-Connecting-O2O` | An edge routing marker |
| `CF-Ray` | Cloudflare's request ID |
| `CF-Visitor` | The scheme the caller used |
| `CDN-Loop` | Loop detection |
| `CF-Worker` | Edge routing detail |
| `CF-EW-Via` | Edge routing detail |
| `CF-IPCountry` | The caller's location |
| `CF-IPCity` | The caller's location |
| `CF-IPContinent` | The caller's location |
| `CF-IPLatitude` | The caller's location |
| `CF-IPLongitude` | The caller's location |
| `CF-Region` | The caller's location |
| `CF-Region-Code` | The caller's location |
| `CF-Metro-Code` | The caller's location |
| `CF-Postal-Code` | The caller's location |
| `CF-Timezone` | The caller's location |
| `CF-Bot-Score` | Cloudflare's bot assessment of the caller |
| `CF-Verified-Bot` | Cloudflare's bot assessment of the caller |
| `CF-JA3-Hash` | The caller's TLS fingerprint |
| `CF-JA4` | The caller's TLS fingerprint |
| `Exposed-Credential-Check` | Cloudflare's leaked-credentials check |
| `Malicious-Uploads-Detection` | Cloudflare's upload scan |
| `Cf-Access-Jwt-Assertion` | A Cloudflare Access token for a signed-in person |
| `CF-Cert-Presented` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Verified` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Revoked` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Issuer-DN` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Subject-DN` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Issuer-DN-RFC2253` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Subject-DN-RFC2253` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Issuer-DN-Legacy` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Subject-DN-Legacy` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Serial` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Issuer-Serial` | The caller's client certificate, as the edge saw it |
| `CF-Cert-SHA256` | The caller's client certificate, as the edge saw it |
| `CF-Cert-SHA1` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Not-Before` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Not-After` | The caller's client certificate, as the edge saw it |
| `CF-Cert-SKI` | The caller's client certificate, as the edge saw it |
| `CF-Cert-Issuer-SKI` | The caller's client certificate, as the edge saw it |
### Headers that belong to one connection
These describe a single network hop. KnoxCall opens its own connection to your
upstream and sets what that connection needs itself.
| Header | What it is |
| - | - |
| `Host` | Set from your upstream's URL |
| `Content-Length` | Set from the body KnoxCall sends |
| `Connection` | Hop-by-hop |
| `Keep-Alive` | Hop-by-hop |
| `Proxy-Authenticate` | Hop-by-hop |
| `Proxy-Authorization` | Hop-by-hop |
| `TE` | Hop-by-hop |
| `Trailers` | Hop-by-hop |
| `Transfer-Encoding` | Hop-by-hop |
| `Upgrade` | Hop-by-hop |
## Each path also keeps back its own credentials
The list above is shared by every path. Each path additionally keeps back the
credential you used to authenticate to KnoxCall on that path:
* **Routes.** `Authorization` and `DPoP` when the `Authorization` value is a KnoxCall
access token (`Bearer kc_…` or `DPoP kc_…`), and `X-Api-Key` when it holds a
KnoxCall credential. An `Authorization` or `X-Api-Key` that carries your upstream's
own credential is forwarded.
* **AI Gateway.** `Authorization`, `X-Api-Key`, `X-Knox-AI-Key` and `DPoP`. The
gateway authenticates you with these and supplies your provider's credential from
the route.
* **Ephemeral proxy.** `Authorization`, every header beginning `X-Knox-`, and
`X-Api-Key` when it holds a KnoxCall credential. See
[Invoke the ephemeral proxy](/api-reference/ephemeral-proxy/invoke).
* **Inbound-webhook forwards.** Every header beginning `X-Knox-`. KnoxCall sets
those itself to tell your receiver whether the signature verified. A delivery
whose signature did not verify, where you have chosen to forward those, carries
`Content-Type` and KnoxCall's own `X-Knox-` headers and nothing else the sender
sent.
## Cloudflare Access credentials for your own upstream are forwarded
If your upstream sits behind Cloudflare Access, the headers your caller sends to
authenticate to it are yours and are forwarded: `CF-Access-Client-Id`,
`CF-Access-Client-Secret` and `cf-access-token`. Only the token Cloudflare Access adds
on the way in to KnoxCall, `Cf-Access-Jwt-Assertion`, is withheld.
## If your upstream needs one of these headers
There is no response header or error when a header is withheld. The request is
forwarded without it.
* **On a route, including the route behind an AI Gateway agent**, add the header to
the route's injected headers. A header the route injects is sent to your upstream
even when its name is in one of the first three tables above; the route's value is
sent and the value your caller put in that header is not. There is no template
helper for the caller's IP address, so this suits a fixed value. The
connection-level headers in the last table are always set by KnoxCall.
* **On the ephemeral proxy and on inbound-webhook forwards** there are no injected
headers, so a header on this list cannot be sent to the upstream on those paths.
Send the value under a header name that is not on this list.
# The KnoxCall Response Block
Source: https://docs.knoxcall.com/api-reference/response-metadata
Tell a KnoxCall error from your provider's, and see exactly where the time went
# The KnoxCall response block
When you call a route through KnoxCall, the status, headers and body you get back
are **your upstream's, forwarded verbatim**. That is deliberate: your code should
see what your provider actually said, byte for byte.
It leaves one question the response alone cannot answer. A `429` came back — was
that your provider's rate limit, or ours? A `502` — did the call reach them at
all? The KnoxCall response block answers it, on every response, without touching
the body your provider sent.
The whole `x-knox-` **response** namespace is reserved. If your upstream sends
a header in it, KnoxCall drops it rather than relaying it. Everything below is
always KnoxCall speaking, never something an upstream can forge.
## The one check that matters
**`X-Knox-Upstream-Status` is present if and only if a real upstream response was
received.**
```text theme={"dark"}
# Your provider answered. This 429 is theirs.
HTTP/1.1 429 Too Many Requests
X-Knox-Origin: upstream
X-Knox-Upstream-Status: 429 ← present
# KnoxCall answered. The call never left our network.
HTTP/1.1 429 Too Many Requests
X-Knox-Origin: knoxcall
X-Knox-Error: route_rate_limited
← no X-Knox-Upstream-Status
```
If you wrap a provider SDK, branch on that absence. Without it, a client library
reads our `429` as the provider's and retries against a limit that is not the one
refusing it — backing off in the wrong place, for the wrong reason, until the
provider's own limiter finally trips.
```js theme={"dark"}
const gatewayError = !res.headers.get('x-knox-upstream-status');
if (gatewayError) {
// KnoxCall refused this. res.headers.get('x-knox-error') says why.
// Do NOT hand it to the provider SDK's retry logic.
}
```
`X-Knox-Origin` says the same thing in a word, for when you are reading `curl -i`
rather than writing code.
## Every header
| Header | Meaning |
| - | - |
| `X-Knox-Request-Id` | The call id. Quote it to support; it matches your logs. |
| `X-Knox-Origin` | `upstream` or `knoxcall` — who produced this status and body. |
| `X-Knox-Upstream-Status` | The status your upstream returned. **Present only on a genuine forward.** |
| `X-Knox-Error` | KnoxCall's error code, on a KnoxCall-originated refusal. |
| `X-Knox-Plane` | Which KnoxCall surface answered: `route`, `proxy`, `ai`. |
| `X-Knox-Sandbox` | `true` when the call ran against your Test data space. |
| `X-Knox-Latency-Ms` | Total milliseconds inside KnoxCall. |
| `X-Knox-Latency-Phase` | What that number measured — see below. |
| `X-Knox-Upstream-Ms` | Milliseconds spent waiting on your provider. |
| `X-Knox-Overhead-Ms` | Milliseconds spent in KnoxCall itself. |
| `X-Knox-RateLimit-Route` | `limit=…, remaining=…, reset=…` for the route limit. |
| `X-Knox-RateLimit-Tenant` | The same, for your account-wide limit, plus `burst=…` — see below. |
| `X-Knox-RateLimit-Monthly` | The same, for your plan's monthly call ceiling. |
| `Server-Timing` | `knoxcall;dur=…, knoxcall_upstream;dur=…`, so your browser's network panel shows the split. Appended to any `Server-Timing` your upstream sent, never replacing it. |
## Latency, and what it measured
`X-Knox-Latency-Ms` cannot mean the same thing on a buffered and a streamed
response, so it is labelled rather than left ambiguous:
* **`X-Knox-Latency-Phase: complete`** — the whole body was in hand. The number is
the full round trip.
* **`X-Knox-Latency-Phase: first-byte`** — the response is streaming. Headers must
be sent before any body byte, so this is time-to-first-byte and the total is not
knowable yet.
`X-Knox-Upstream-Ms` and `X-Knox-Overhead-Ms` split that time between your provider
and us. **Both are omitted when the split was not measured** rather than estimated —
if you see them, they were timed. The upstream clock starts once your provider's
address is resolved, so DNS lookup time counts as KnoxCall overhead, not provider
time.
The same split is stored on the request's log row. The log detail page's Proxy tab
shows **Proxy Latency** as that overhead figure (total minus upstream), so what you
read there and what the header said for the same request agree.
## Rate limits
Each limiter reports its own window, so three different ceilings do not collapse
into one ambiguous pair of numbers:
```text theme={"dark"}
X-Knox-RateLimit-Route: limit=100, remaining=97, reset=2026-09-21T10:00:00.000Z
X-Knox-RateLimit-Tenant: limit=10000, remaining=1583, reset=2026-09-21T10:00:01.000Z, burst=1667
X-Knox-RateLimit-Monthly: limit=100000, remaining=41233
```
`reset` is an ISO-8601 instant. It is absent on the monthly window, which rolls
over with your billing period rather than on a fixed clock.
The account-wide (tenant) limit is a token bucket, so its window carries a fourth
key, appended last so a parser of the first three is unaffected:
* `limit` — your plan's sustained rate, in requests **per minute**.
* `burst` — the most requests admitted **at once**: the bucket, ten seconds of
`limit` on every plan (17 / 167 / 834 / 1,667 for Free / Starter / Pro /
Enterprise).
* `remaining` — requests you can send right now. Never more than `burst`.
* `reset` — when the bucket is full again, so never more than about ten seconds
away.
A `429` from the tenant limit while you are under your per-minute `limit` means
the burst ran out; `X-RateLimit-Layer: tenant` and `X-RateLimit-Burst` on that
response say so.
The older `X-RateLimit-Limit`, `X-RateLimit-Tenant-*` and `X-API-Usage-*` headers
are still sent and still correct. They are deprecated in favour of the block
above — the three families used different names, units and reset formats, and
none of them said whether the limit that refused you was KnoxCall's or your
provider's. Nothing that reads them today needs to change yet.
## On errors KnoxCall itself returns
When KnoxCall refuses a call, the body is ours, so the block is repeated there as
a `knoxcall` object alongside the usual `error` envelope:
```json theme={"dark"}
{
"error": {
"type": "route_rate_limited",
"message": "Route rate limit exceeded. Try again in 12 seconds.",
"request_id": "0f9c1a52-…"
},
"knoxcall": {
"request_id": "0f9c1a52-…",
"origin": "knoxcall",
"plane": "route",
"latency_ms": 3,
"latency_phase": "complete",
"code": "route_rate_limited",
"message": "Route rate limit exceeded. Try again in 12 seconds.",
"rate_limit": {
"route": { "limit": 100, "remaining": 0, "reset": "2026-09-21T10:00:00.000Z" }
}
}
}
```
The `knoxcall` key is a sibling of `error`, not nested inside it, so the existing
error envelope is unchanged and a client that does not know about the block simply
ignores it.
### When KnoxCall itself could not serve the call
If a KnoxCall dependency was unreachable or did not answer in time, the call is
refused rather than forwarded unmetered or served from stale data:
```text theme={"dark"}
HTTP/1.1 503 Service Unavailable
Retry-After: 5
X-Knox-Origin: knoxcall
X-Knox-Error: dependency_unavailable
```
```json theme={"dark"}
{
"error": {
"type": "dependency_unavailable",
"message": "KnoxCall could not reach its control plane in time. The request was not forwarded; retry after the Retry-After interval.",
"request_id": "0f9c1a52-…",
"dependency": "control_plane",
"retry_after": 5
},
"knoxcall": { "request_id": "0f9c1a52-…", "origin": "knoxcall", "plane": "route", "code": "dependency_unavailable", "…": "…" }
}
```
`dependency` is `control_plane` or `rate_limit_store`. Treat it like any other
503: wait `Retry-After`, then retry — the request never reached your provider,
so it is safe to resend. It is not a credential problem and it is not a
rate-limit hit; do not refresh credentials or back off against your own limits
because of it. The official SDKs already do this.
On a **forwarded** response the block stays in the headers only. Your upstream's
body is passed through untouched — it may be JSON, a PDF, an image or a stream, and
KnoxCall does not rewrite any of them.
# Create Route
Source: https://docs.knoxcall.com/api-reference/routes/create
Creates a new route in your tenant
## Create Route
```text theme={"dark"}
POST /v1/routes
```
Creates a new route in your tenant.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the route |
| `target_base_url` | string | Yes | The upstream API base URL to proxy requests to |
| `base_environment` | string | No | Default environment (defaults to `production`) |
| `inject_headers_json` | object | No | Headers to inject into proxied requests |
| `inject_body_json` | object | No | Body fields to inject into proxied requests |
| `intercept_enabled` | boolean | No | Opt the base environment into [route-aware interception](/essentials/routes/route-aware-interception): SDK interceptors and the agent's intercept mode send matching third-party traffic through this route. Default `false`. Refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in that environment. |
| `injection_rules` | object\[] | No | **Removed.** A non-empty value is rejected with 400 — it was stored and applied to nothing. Use `{{secret_id:}}` / `{{secret:}}` placeholders in the request body your client sends, for a secret the route's own templates already send ([which secrets a client may name](/essentials/secrets/using-secrets#which-secrets-your-client-may-name)). An empty array is still accepted as a no-op. |
| `ip_allowlist` | string\[] | No | IP addresses or CIDR ranges allowed to call this route |
| `method_configs` | object | No | Per-HTTP-method configuration overrides |
| `collection_id` | string | No | Assign to a collection |
### Response
Returns the newly created route record (the full `routes` table row, including all columns). Note this differs from the [Get Route](./get) response, which returns a projected shape with additional derived fields (`environment_override_count`, `payload_structure`, `injection_rules`, `ip_allowlist`, `data_plane_node_id`, and `configured_environments`) that are not included here. Call [Get Route](./get) to retrieve those fields.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const route = await client.routes.create({
name: "Payment Gateway",
target_base_url: "https://api.stripe.com/v1"
});
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
route = client.routes.create(
name="Payment Gateway",
target_base_url="https://api.stripe.com/v1"
)
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Payment Gateway",
"target_base_url": "https://api.stripe.com/v1"
}'
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields or invalid environment |
| 403 | `plan_limit` | Route limit reached for your subscription plan |
| 409 | `conflict` | A route with this name already exists |
# Delete Route
Source: https://docs.knoxcall.com/api-reference/routes/delete
Permanently deletes a route and all of its environment overrides
## Delete Route
```text theme={"dark"}
DELETE /v1/routes/:id
```
Permanently deletes a route and all of its environment overrides.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Route not found |
# Route Environments
Source: https://docs.knoxcall.com/api-reference/routes/environments
List, set, and delete environment-specific configuration overrides for a route
## List Route Environments
```text theme={"dark"}
GET /v1/routes/:id/environments
```
Returns all environment-specific configuration overrides for a route.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
### Response
```json theme={"dark"}
{
"data": [
{
"environment_name": "staging",
"target_base_url": "https://api-staging.stripe.com/v1",
"inject_headers_json": {},
"inject_body_json": {},
"intercept_enabled": false,
"require_signature": false,
"signature_tolerance_sec": null,
"rate_limit_enabled": false,
"rate_limit_requests": null,
"rate_limit_window_sec": null,
"rate_limit_burst": null,
"allowed_methods": null
}
],
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Route not found |
***
## Set Route Environment Override
```text theme={"dark"}
PUT /v1/routes/:id/environments/:env
```
Creates or replaces the environment-specific configuration for a route. This lets you point the same route at a different upstream URL, inject different headers, or apply different rate limits per environment.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
| `env` | string | Environment name (e.g., `staging`, `development`) |
### Request Body
All fields are optional. Only provided fields will override the base route configuration for this environment; a field you omit keeps its stored value. `target_base_url` is required when the override does not exist yet (`400 validation_error` otherwise).
| Field | Type | Description |
| - | - | - |
| `target_base_url` | string | Override upstream URL |
| `inject_headers_json` | object | Override injected headers |
| `inject_body_json` | object | Override injected body fields |
| `intercept_enabled` | boolean | Opt this environment into [route-aware interception](/essentials/routes/route-aware-interception). Turning it on is refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in this environment. |
| `require_signature` | boolean | Override signature requirement |
| `signature_tolerance_sec` | number | Override signature tolerance |
| `rate_limit_enabled` | boolean | Override rate limiting |
| `rate_limit_requests` | number | Override max requests |
| `rate_limit_window_sec` | number | Override window duration |
| `rate_limit_burst` | number | Override burst allowance |
| `allowed_methods` | string\[] | Override allowed HTTP methods |
### Response
Returns the environment config object.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Invalid environment name |
| 403 | `forbidden` | The route carries a standing `decrypt` or `detokenize` field action and this write would give it a destination it does not already store, or switch on one it stores but does not send to: a token issued to a person is refused whatever the role, and a key must hold the action's own permission (`transit:decrypt` / `vault:detokenize`). See [Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes). |
| 403 | `insufficient_scope` | The same, for an OAuth token narrowed to scopes that do not include the action's own (`transit:decrypt` / `vaults:detokenize`); the `WWW-Authenticate` header names it. |
| 404 | `not_found` | Route not found |
***
## Delete Route Environment Override
```text theme={"dark"}
DELETE /v1/routes/:id/environments/:env
```
Removes the environment-specific override, reverting the route to its base configuration for that environment.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
| `env` | string | Environment name |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Route or environment config not found |
# Get Route
Source: https://docs.knoxcall.com/api-reference/routes/get
Returns the full configuration for a single route
## Get Route
```text theme={"dark"}
GET /v1/routes/:id
```
Returns the full configuration for a single route, including injection rules, environment overrides, and payload structure.
`payload_structure` is the base environment's saved sample request body as its **structure only**: the same keys and nesting, with every value replaced by a type placeholder (`""`, `""`, `""`; `null` stays `null`). The sample's values are never returned by the API — a saved sample is usually a captured request, so its values are a recorded reveal for an Admin or the Owner in the dashboard.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Payment Gateway",
"target_base_url": "https://api.stripe.com/v1",
"base_environment": "production",
"enabled": true,
"requires_clients": false,
"methods": null,
"mtls_certificate_id": null,
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"environment_override_count": 2,
"payload_structure": {
"order": { "id": "", "amount": "", "paid": "" }
},
"injection_rules": null,
"ip_allowlist": null,
"data_plane_node_id": null,
"configured_environments": ["production", "staging"]
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Route not found |
# List Routes
Source: https://docs.knoxcall.com/api-reference/routes/list
Returns a paginated list of all routes in your tenant
## List Routes
```text theme={"dark"}
GET /v1/routes
```
Returns a paginated list of all routes in your tenant.
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
| `sort` | string | `created_at` | Sort by: `created_at`, `name`, or `enabled` |
| `order` | string | `desc` | Sort direction: `asc` or `desc` |
| `enabled` | boolean | -- | Filter by enabled status |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Payment Gateway",
"target_base_url": "https://api.stripe.com/v1",
"base_environment": "production",
"enabled": true,
"requires_clients": false,
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"environment_override_count": 2,
"require_signature": false,
"rate_limit_enabled": true,
"rate_limit_requests": 100,
"rate_limit_window_sec": 60,
"allowed_methods": ["GET", "POST"]
}
],
"meta": {
"total": 24,
"page": 1,
"per_page": 20,
"total_pages": 2,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const page = await client.routes.list({ page: 1, per_page: 20 });
const routes = page.data;
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
page = client.routes.list(page=1, per_page=20)
routes = page["data"]
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl "https://api.knoxcall.com/v1/routes?page=1&per_page=20" \
-H "Authorization: Bearer $TOKEN"
```
# Get Route Logs
Source: https://docs.knoxcall.com/api-reference/routes/logs
Returns paginated request logs for a specific route
## Get Route Logs
```text theme={"dark"}
GET /v1/routes/:id/logs
```
Returns paginated request logs for a specific route, showing every proxied request with status, latency, and geographic data.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "log-uuid",
"request_id": "req_a1b2c3d4",
"ts": "2026-03-10T14:22:01.000Z",
"src_ip": "203.0.113.42",
"method": "POST",
"path": "/v1/charges",
"status_code": 200,
"latency_ms": 187,
"upstream_host": "api.stripe.com",
"error": null,
"environment": "production",
"rate_limited": false,
"signature_valid": true,
"source_ip_country": "US",
"source_ip_city": "San Francisco"
}
],
"meta": {
"total": 1482,
"page": 1,
"per_page": 20,
"total_pages": 75,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Route not found |
# Update Route
Source: https://docs.knoxcall.com/api-reference/routes/update
Updates one or more fields on an existing route
## Update Route
```text theme={"dark"}
PATCH /v1/routes/:id
```
Updates one or more fields on an existing route. Only include the fields you want to change.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The route ID |
### Request Body
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
| - | - | - |
| `name` | string | Display name |
| `enabled` | boolean | Enable or disable the route |
| `requires_clients` | boolean | Whether requests must come from an authorized client |
| `target_base_url` | string | Upstream API base URL |
| `inject_headers_json` | object | Headers to inject |
| `inject_body_json` | object | Body fields to inject |
| `intercept_enabled` | boolean | Per-environment field (the base environment): opt into [route-aware interception](/essentials/routes/route-aware-interception). Turning it on is refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in that environment. |
| `method_configs` | object | Per-HTTP-method configuration |
| `injection_rules` | object\[] | **Removed.** A non-empty value is rejected with 400 — it was stored and applied to nothing. Use `{{secret_id:}}` / `{{secret:}}` placeholders in the request body your client sends, for a secret the route's own templates already send ([which secrets a client may name](/essentials/secrets/using-secrets#which-secrets-your-client-may-name)). An empty array is still accepted as a no-op. |
| `require_signature` | boolean | Require request signature validation |
| `signature_tolerance_sec` | number | Signature timestamp tolerance in seconds |
| `rate_limit_enabled` | boolean | Enable rate limiting |
| `rate_limit_requests` | number | Max requests per window |
| `rate_limit_window_sec` | number | Rate limit window in seconds |
| `rate_limit_burst` | number | Burst allowance |
| `allowed_methods` | string\[] | Restrict to specific HTTP methods |
| `http_method_restrictions_enabled` | boolean | Enable method restrictions |
| `egress_server_id` | string | Route through a specific egress/VPN server |
| `ip_allowlist` | string\[] | Allowed IP addresses or CIDR ranges |
| `data_plane_node_id` | string | Assign to a specific data plane node |
### Response
Returns the updated route object.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | No valid fields provided |
| 403 | `forbidden` | The route carries a standing `decrypt` or `detokenize` field action and this write would give it a destination it does not already store, or switch on one it stores but does not send to: a token issued to a person is refused whatever the role, and a key must hold the action's own permission (`transit:decrypt` / `vault:detokenize`). See [Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes). |
| 403 | `insufficient_scope` | The same, for an OAuth token narrowed to scopes that do not include the action's own (`transit:decrypt` / `vaults:detokenize`); the `WWW-Authenticate` header names it. |
| 404 | `not_found` | Route not found |
# SCIM 2.0 Provisioning
Source: https://docs.knoxcall.com/api-reference/scim
Connect Microsoft Entra ID, Okta or any RFC 7644 client to KnoxCall: base URL, token issuance, supported resources and filters, the attributes we accept but never store, the Enterprise requirement, and the rate limits your connector must honour.
# SCIM 2.0 Provisioning
KnoxCall implements [RFC 7644](https://www.rfc-editor.org/rfc/rfc7644) (SCIM Protocol) and
[RFC 7643](https://www.rfc-editor.org/rfc/rfc7643) (SCIM Schema) so your identity provider can
create, update and deprovision workspace members — and OAuth clients — without anyone logging
into KnoxCall.
This is the page your connector's `documentationUri` points at. If you arrived here from
`GET /scim/v2/ServiceProviderConfig`, you are in the right place.
**SCIM provisioning requires the Enterprise plan.** Discovery
(`/ServiceProviderConfig`, `/Schemas`, `/ResourceTypes`) is served on every plan so that a
connector can be configured and can read this URL; the `/Users` and `/OAuthClients`
endpoints are served to Enterprise workspaces. See
[Plan requirement](#plan-requirement) for exactly what a workspace sees when the plan does
not cover it.
## Base URL
The SCIM service lives under `/scim/v2` on your workspace's KnoxCall control-plane host:
```
https://.knoxcall.com/scim/v2
```
`https://admin.knoxcall.com/scim/v2` works too. Requests that arrive on any other hostname —
including directly to the origin address — are answered `404` before authentication is
attempted, so paste the workspace URL exactly.
Every endpoint is relative to that base:
| Method | Path | What it does |
| - | - | - |
| `GET` | `/ServiceProviderConfig` | Capability document (RFC 7643 §5) |
| `GET` | `/Schemas`, `/Schemas/{id}` | Attribute definitions |
| `GET` | `/ResourceTypes` | The resources this service exposes |
| `GET` `POST` | `/Users` | List / filter, and provision a member |
| `GET` `PUT` `PATCH` `DELETE` | `/Users/{id}` | Read, replace, patch, deprovision |
| `GET` `POST` | `/OAuthClients` | List / filter, and provision an OAuth client |
| `GET` `PUT` `PATCH` `DELETE` | `/OAuthClients/{id}` | Read, replace, patch, revoke |
## Token issuance
Your connector authenticates with a **SCIM credential** — a bearer token that begins with
`scim_`. It is not a user's session and not an API key; it belongs to the workspace, carries
its own tenant binding, and reaches `/scim/v2` and nothing else.
To issue one, an **owner or admin** goes to the KnoxCall dashboard and mints a credential from
the Single sign-on settings (`POST /admin/scim/credentials` if you are scripting it). Minting
requires a recent second-factor step-up made with `"action_scope": "scim.credential"` (an
emailed code does not count), is written to the audit log, and asks for an explicit
expiry — a credential with no expiry is something you have to choose, never something you get
by omitting a field.
```http theme={"dark"}
GET /scim/v2/Users?filter=userName%20eq%20%22ada@example.com%22 HTTP/1.1
Host: acme.knoxcall.com
Authorization: Bearer scim_3f9c…
Accept: application/scim+json
```
The token is shown **exactly once**, when it is minted. It is stored only as a SHA-256 digest,
so it cannot be recovered — if it is lost, revoke it and mint another.
Use a **Live** credential for `/Users`. Workspace membership is not partitioned into Live and
Test data spaces — a workspace has one set of people — so a Test credential is refused on
`/Users` with a `403`. A Test credential does provision `/OAuthClients`, which *is*
partitioned.
Revoking a credential takes effect on the connector's next request: the check is a single
indexed lookup evaluated against the database clock, not a cache.
## Content type
RFC 7644 §3.1 requires `application/scim+json`, and KnoxCall accepts it on requests and returns
it on responses. `application/json` is accepted on requests as well, so a connector that sends
the plain type is not broken by it.
## Supported resources
### `/Users`
A `User` is a **workspace membership plus a directory record**. The attributes your IdP asserts
live on the directory record, never on the person's global KnoxCall account — one workspace's
directory must not rename or re-address someone in another workspace.
| Attribute | Notes |
| - | - |
| `userName` | Required, unique within the workspace, **case-insensitive** |
| `name.givenName`, `name.familyName`, `name.formatted` | Separate fields, because Entra patches them one operation at a time |
| `displayName` | As this workspace's directory asserts it |
| `emails[type eq "work"]` | Held on the directory record; it never moves the KnoxCall login address |
| `active` | **Derived**, not stored — true exactly while the person holds a live membership on an active account |
| `externalId` | Your directory's own identifier; reconciliation matches on it |
`active` being derived is the property that matters most in practice: if an administrator
removes someone from the workspace on the team page, your next `GET` reports `active: false`
without KnoxCall having to write anything.
**Provisioning a new person requires a verified domain.** SCIM grants membership without asking
the subject to accept an invitation, so KnoxCall applies the same admission rule as SAML
just-in-time provisioning: the address must sit within a domain this workspace has proven by
DNS. Verify the domain under Settings → Single sign-on first. Someone who is **already** a
member can be given a directory record without this.
New memberships are always granted at the `member` role. A SCIM connector cannot mint an
administrator.
#### Deprovisioning
Both forms end the membership immediately, and **neither erases personal data** — erasure is a
separate, subject-initiated process:
* `PATCH` with `active: false` — the membership is revoked and the directory record **stays**,
so a later `GET` returns it with `active: false`. This is the shape Entra sends on a
soft-delete.
* `DELETE` — the same revocation, plus a tombstone on the directory record, so a later `GET`
returns `404`.
### `/OAuthClients`
A custom resource, `urn:knoxcall:scim:2.0:OAuthClient`, for provisioning machine credentials
alongside people. `clientName`, `allowedScopes`, `redirectUris`, `requirePkce`, `requireDpop`,
`stepUpScopes` and `active` are writable; `clientId`, `clientType` and `grantTypes` are not.
The `client_secret` of a confidential client is returned exactly once, on create.
Redirect URIs are validated on registration with the same rule the dashboard and the
Management API apply — a non-registrable URI is refused with a `400` rather than silently
dropped from the list.
## Filtering
`filter` implements the RFC 7644 §3.4.2.2 grammar, not a subset of it:
* Comparison operators `eq`, `ne`, `co`, `sw`, `ew`, `gt`, `ge`, `lt`, `le`, and the presence
operator `pr`
* `and`, `or`, `not`, and parentheses
* Value filters on multi-valued attributes — `emails[type eq "work"]`
* URN-qualified attribute names
Attribute names are matched case-insensitively, as RFC 7643 §2.1 requires, except for the
identifiers `id`, `externalId` and `clientId`, which RFC 7643 §7 defines as case-exact.
`userName` deliberately folds case, so `userName eq "Ada@Example.com"` finds the record stored
as `ada@example.com`.
A filter KnoxCall cannot honour is answered `400` with `scimType: "invalidFilter"` — it is
never ignored, and you will never receive an unfiltered list in response to a filter we did not
understand.
Pagination is `startIndex` (1-based) and `count`, capped at the `filter.maxResults` value
`/ServiceProviderConfig` advertises.
## Attributes accepted but not stored
Entra's default user mapping sends attributes KnoxCall has no home for. Rejecting them would
refuse the **whole** `PatchOp` — including the `active: false` in the same request — so an
offboarding would silently not happen. KnoxCall therefore accepts these, names them in the
audit record, and stores none of them:
| Schema | Attributes |
| - | - |
| `urn:ietf:params:scim:schemas:core:2.0:User` | `title`, `preferredLanguage`, `addresses[…]`, `phoneNumbers[…]` |
| `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` | `employeeNumber`, `costCenter`, `organization`, `division`, `department`, `manager` |
Anything outside that set — an unknown core attribute, a custom extension, a foreign schema URN
— is still refused with `scimType: "invalidPath"`, and the schema qualifier on a path is read,
so `urn:…:enterprise:2.0:User:active` does **not** deactivate anyone.
## Plan requirement
SCIM provisioning is an **Enterprise** feature.
**Discovery is never gated.** `/ServiceProviderConfig`, `/Schemas` and `/ResourceTypes` answer
on every plan, so a connector can always be configured and can always read this page's URL out
of the capability document.
**The resource endpoints are.** A workspace whose plan does not include SCIM receives `403` on
`/Users` and `/OAuthClients` with a SCIM Error object:
```json theme={"dark"}
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "403",
"detail": "SCIM provisioning requires the Enterprise plan. …",
"error": "scim_plan_required"
}
```
The `error` member is a KnoxCall extension, because RFC 7644 §3.12's `scimType` vocabulary is
closed and has no value meaning "your plan does not include this". A SCIM client ignores the
extra member; `schemas`, `status` and `detail` are exactly what the RFC specifies. There is no
`scimType` on this refusal — giving it a near-miss value would make a connector retry with a
different body forever.
### What happens if the subscription lapses
**Nothing stops.** A workspace whose Enterprise subscription goes `past_due`, enters dunning or
is cancelled **keeps being provisioned**. An identity provider does not read a `403` as "the
invoice is unpaid" — Entra marks the object in error, retries, and eventually quarantines the
whole provisioning job, and restarting a quarantined job re-runs a full reconciliation. Turning
a card decline into that would churn every member of the workspace at the moment the people who
could fix the card are the ones losing access.
What a lapse does stop is **issuing a new SCIM credential**. Minting requires a current
Enterprise plan; connectors that already hold one keep working. A workspace that deliberately
**downgrades** to a lower tier is a different case — that is a choice, not an accident of
billing — and its resource endpoints are refused as above.
Listing and revoking credentials is never gated. A workspace that has stopped paying must still
be able to see which connectors hold a credential and switch them off.
## Rate limits
An identity provider does not send traffic like a person. Entra reconciles in bursts, retries
aggressively, and treats a `429` without a `Retry-After` header as a hard failure rather than
as backpressure. KnoxCall's limits are built around that.
| | |
| - | - |
| **Unit** | Per workspace *and per credential type*, not per source address — your connector's budget is its own, and is never spent by administrators using the SCIM endpoints from a browser |
| **Budget** | 600 requests per 60 seconds, plus a 150-request burst — 750 in any 60-second window |
| **On exhaustion** | `429`, always with `Retry-After` in seconds |
| **If metering is unavailable** | `503`, also with `Retry-After` — a KnoxCall-side fault, never reported as `429` |
The unit is the **workspace** deliberately. Entra's provisioning service egresses from a shared
Azure address pool, so two KnoxCall customers' reconciliations routinely arrive from the same
IP; a purely per-address budget would let one customer's initial sync throttle another's
connector. A separate per-address flood shield still sits in front of the mount, set above the
per-workspace budget so that one workspace can never consume it on its own.
A `429` body is a SCIM Error object like any other refusal:
```http theme={"dark"}
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/scim+json
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "429",
"detail": "Too many SCIM requests for this workspace. …",
"error": "scim_rate_limited"
}
```
**Honour `Retry-After`.** A provisioning cycle interrupted by a `429` can be resumed without
losing state — nothing was half-applied, because the request was shed before it reached a
handler. At 750 requests per minute a full first-time synchronisation of 10,000 users completes
in about fifteen minutes, and an incremental cycle never approaches the ceiling.
## Error codes
Alongside the RFC 7644 §3.12 members, KnoxCall refusals that the `scimType` vocabulary cannot
express carry an `error` member:
| `error` | Status | Meaning |
| - | - | - |
| `scim_plan_required` | `403` | The workspace's plan does not include SCIM provisioning |
| `scim_rate_limited` | `429` | The workspace's SCIM budget is spent; retry after `Retry-After` |
| `scim_rate_limiter_unavailable` | `503` | KnoxCall could not meter the request and did not serve it |
| `scim_users_live_only` | `403` | A Test credential was presented to `/Users` |
| `operator_custody_blocked` | `403` | A KnoxCall operator is not permitted to act on this workspace's credentials |
A refusal never states whether some other workspace exists. Your credential binds the request
to one workspace, so there is nothing to disambiguate.
## Setting up Microsoft Entra ID
1. Mint a SCIM credential in KnoxCall (Settings → Single sign-on) and copy the `scim_…` token.
2. In Entra, open your enterprise application → **Provisioning** → **Automatic**.
3. **Tenant URL**: `https://.knoxcall.com/scim/v2`
4. **Secret Token**: the `scim_…` token.
5. **Test Connection** — Entra fetches `/ServiceProviderConfig` and `/Schemas`.
6. Leave the default user attribute mapping as it is. The attributes KnoxCall does not store
are accepted rather than refused, precisely so that the default mapping works unmodified.
7. Verify your email domain in KnoxCall before starting the first cycle, or every create for a
new person is refused.
Okta is configured the same way: **Base URL** `https://.knoxcall.com/scim/v2`,
OAuth Bearer Token, and *Import New Users and Profile Updates* + *Push New Users* +
*Push Profile Updates* + *Deactivate Users*.
## What KnoxCall does not implement
`/ServiceProviderConfig` is the authoritative answer, and it says so honestly:
* **Bulk** operations — not supported
* **Sorting** — not supported
* **ETags** — not supported
* **`changePassword`** — not supported, and never will be: KnoxCall does not hold member
passwords
* **`/Groups`** — not yet exposed
# Create Secret
Source: https://docs.knoxcall.com/api-reference/secrets/create
Creates a new encrypted string secret
## Create Secret
```http theme={"dark"}
POST /v1/secrets
```
Creates a new encrypted secret. Pass `secret_type` to choose the variant — the default is `string`.
For type-specific convenience endpoints see [Create OAuth2 Secret](./create-oauth2) and [Create Certificate Secret](./create-certificate).
Secret names are normalized: spaces are replaced with underscores. For example, `"Stripe API Key"` becomes `STRIPE_API_KEY`. The normalized name is returned as both `name` and `shortcode_name`.
### String Secret
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the secret (spaces → underscores) |
| `value` | string | Yes | The secret value to encrypt and store |
| `collection_id` | uuid | No | Assign to a collection |
| `expires_at` | string | No | ISO-8601 datetime after which the secret is considered expired |
| `strict_expiry_enforcement` | boolean | No | If `true`, injection is blocked when the secret is expired. Defaults to `false` (warn-only) |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "STRIPE_API_KEY",
"shortcode_name": "STRIPE_API_KEY",
"base_environment": "production",
"environment_count": 1,
"secret_type": "string",
"collection_id": null,
"expires_at": null,
"strict_expiry_enforcement": false
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const secret = await client.secrets.create({
name: "STRIPE_API_KEY",
value: "sk_live_..."
});
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
secret = client.secrets.create(
name="STRIPE_API_KEY",
secret_type="string",
value="sk_live_..."
)
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "STRIPE_API_KEY",
"value": "sk_live_..."
}'
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields or invalid collection |
| 403 | `plan_limit` | Secret limit reached for your subscription plan |
| 409 | `conflict` | A secret with this name already exists |
# Create Certificate Secret
Source: https://docs.knoxcall.com/api-reference/secrets/create-certificate
Creates a certificate-type secret for use with mTLS-authenticated upstream APIs
## Create Certificate Secret
```http theme={"dark"}
POST /v1/secrets/certificate
```
Creates a certificate-type secret for use with mTLS-authenticated upstream APIs. KnoxCall accepts PEM, PKCS#7, CRT/CER, PFX/P12, and KEY formats.
For PEM-format certificates, KnoxCall automatically extracts the subject, issuer, and expiry date and returns them in the response metadata.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Secret name (spaces → underscores) |
| `certificate_content` | string | Yes | Certificate in PEM, PKCS#7, CRT/CER format, or base64-encoded DER/PFX/P12 |
| `private_key` | string | No | PEM-encoded private key to store alongside the certificate |
| `passphrase` | string | No | Passphrase to decrypt the private key. Encrypted and stored separately |
| `certificate_type` | string | No | `pem` (default), `pfx`, `p12`, `crt`, `cer`, `key`, `pkcs7`, `p7b`, or `p7c` |
| `collection_id` | uuid | No | Assign to a collection |
### Response
```json theme={"dark"}
{
"data": {
"id": "b9e3f0a5-4c6d-5e8f-0f1a-3b5d7e9f1a3b",
"name": "MY_TLS_CERT",
"secret_type": "certificate",
"certificate_type": "pem",
"base_environment": "production",
"collection_id": null,
"metadata": {
"subject": "CN=example.com, O=Example Inc, C=US",
"issuer": "CN=Let's Encrypt Authority X3, O=Let's Encrypt, C=US",
"expires_at": "2026-03-15T12:00:00.000Z",
"has_private_key": true
}
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
```typescript Node.js theme={"dark"}
import fs from "fs";
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const secret = await client.secrets.createCertificate({
name: "MY_TLS_CERT",
certificate_type: "pem",
certificate_content: fs.readFileSync("client.pem", "utf8"),
private_key: fs.readFileSync("client.key", "utf8")
});
```
```bash cURL — PEM with private key theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/secrets/certificate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "MY_TLS_CERT",
"certificate_type": "pem",
"certificate_content": "-----BEGIN CERTIFICATE-----\nMIID...==\n-----END CERTIFICATE-----",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...==\n-----END PRIVATE KEY-----"
}'
```
```bash cURL — PEM CA certificate only theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl -X POST https://api.knoxcall.com/v1/secrets/certificate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CA_CERT",
"certificate_type": "pem",
"certificate_content": "-----BEGIN CERTIFICATE-----\nMIID...==\n-----END CERTIFICATE-----"
}'
```
You can also create certificate secrets via `POST /v1/secrets` with `secret_type: "certificate"` and the same body fields.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields or invalid `certificate_type` |
| 403 | `plan_limit` | Secret limit reached for your subscription plan |
| 409 | `conflict` | A secret with this name already exists |
# Create OAuth2 Secret
Source: https://docs.knoxcall.com/api-reference/secrets/create-oauth2
Creates an OAuth2-type secret with automatic token refresh managed by KnoxCall
## Create OAuth2 Secret
```http theme={"dark"}
POST /v1/secrets/oauth2
```
Creates an OAuth2-type secret. KnoxCall stores the OAuth2 configuration encrypted at rest and manages token refresh automatically.
After creation, complete the authorization flow by redirecting your user to the `redirect_uri` returned in the response.
**Supported providers:** `google`, `github`, `salesforce`, `microsoft`, `slack`, `custom`
For `custom` providers, you must supply `auth_url` and `token_url`.
OAuth2 secrets require a **Pro or Enterprise** plan.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Secret name (spaces → underscores) |
| `provider` | string | Yes | OAuth2 provider: `google`, `github`, `salesforce`, `microsoft`, `slack`, or `custom` |
| `client_id` | string | Yes | OAuth2 client ID from your provider's developer console |
| `client_secret` | string | Conditional | OAuth2 client secret. Required unless using `mtls_certificate_id` or `grant_type: implicit` |
| `grant_type` | string | No | `authorization_code` (default), `password`, or `implicit` |
| `scopes` | string\[] | No | OAuth2 scopes to request. Defaults to the provider's recommended scopes |
| `auth_url` | string | No | Authorization endpoint URL. Required for `custom` provider |
| `token_url` | string | No | Token endpoint URL. Required for `custom` provider |
| `username` | string | Conditional | Username for `password` grant type |
| `password` | string | Conditional | Password for `password` grant type |
| `mtls_certificate_id` | uuid | No | ID of a `certificate`-type secret for mTLS auth to the token endpoint |
| `collection_id` | uuid | No | Assign to a collection |
### Response
```json theme={"dark"}
{
"data": {
"id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e",
"name": "GOOGLE_OAUTH",
"secret_type": "oauth2",
"provider": "google",
"redirect_uri": "https://api.knoxcall.com/auth/oauth2/callback/a7c1d8f3-...",
"connection_status": "not_connected",
"base_environment": "production",
"collection_id": null,
"mtls_certificate_id": null
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
Register the returned `redirect_uri` as an authorized redirect URL in your OAuth2 provider's app settings before redirecting users through the authorization flow.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const secret = await client.secrets.createOAuth2({
name: "GOOGLE_OAUTH",
provider: "google",
client_id: "1234567890.apps.googleusercontent.com",
client_secret: "GOCSPX-abc123",
scopes: ["openid", "email", "profile"]
});
// Redirect user to secret.redirect_uri to complete authorization
```
```bash cURL — Authorization Code (Google) theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/secrets/oauth2 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "GOOGLE_OAUTH",
"provider": "google",
"client_id": "1234567890.apps.googleusercontent.com",
"client_secret": "GOCSPX-abc123",
"scopes": ["openid", "email", "profile"]
}'
```
```bash cURL — Custom Provider theme={"dark"}
# $TOKEN is a minted OAuth access token — see /api-reference/authentication
curl -X POST https://api.knoxcall.com/v1/secrets/oauth2 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "MY_OAUTH",
"provider": "custom",
"client_id": "my-client-id",
"client_secret": "my-client-secret",
"auth_url": "https://auth.example.com/authorize",
"token_url": "https://auth.example.com/token",
"scopes": ["read", "write"]
}'
```
You can also create OAuth2 secrets via `POST /v1/secrets` with `secret_type: "oauth2"` and the same body fields.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields, invalid provider, or mTLS certificate not found |
| 403 | `plan_limit` | OAuth2 secrets require Pro or Enterprise plan, or secret limit reached |
| 409 | `conflict` | A secret with this name already exists |
# Delete Secret
Source: https://docs.knoxcall.com/api-reference/secrets/delete
Permanently deletes a secret and all of its environment-specific values
## Delete Secret
```http theme={"dark"}
DELETE /v1/secrets/:id
```
Permanently deletes a secret and all of its environment-specific values.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The secret ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Secret not found |
| 409 | `in_use` | Secret is currently assigned to one or more routes |
You cannot delete a secret that is in use by a route. Remove the secret from all routes first, then delete it.
# Get Secret
Source: https://docs.knoxcall.com/api-reference/secrets/get
Returns metadata for a single secret — the encrypted value is not included
## Get Secret
```http theme={"dark"}
GET /v1/secrets/:id
```
Returns metadata for a single secret. The encrypted value is **not** included.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The secret ID |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "STRIPE_API_KEY",
"shortcode_name": "STRIPE_API_KEY",
"base_environment": "production",
"secret_type": "string",
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"environment_count": 3,
"expires_at": null,
"strict_expiry_enforcement": false,
"environments": [
{
"environment_name": "production",
"value_version": 4,
"updated_at": "2026-03-15T10:30:00.000Z",
"expires_at_override": null
},
{
"environment_name": "staging",
"value_version": 1,
"updated_at": "2026-01-15T09:30:00.000Z",
"expires_at_override": "2026-12-31T00:00:00.000Z"
}
]
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### The `environments` array
One entry per environment that holds a value, ordered by environment name.
| Field | Type | Description |
| - | - | - |
| `environment_name` | string | The environment this entry describes |
| `value_version` | integer | Counter of genuine value writes, starting at 1 |
| `updated_at` | date-time | Last change of **any** kind to this environment's row |
| `expires_at_override` | date-time \| null | Per-environment expiry; null inherits the secret-level `expires_at` |
### Detecting a rotation you did not perform
`value_version` is the only field here that means "the stored value changed".
It increments **only** for writes that replace the value:
* `PUT /v1/secrets/:id/value`
* value and certificate updates made in the KnoxCall dashboard
* platform-managed custodial key rotation
* importing over an existing environment during a secret-store migration
It deliberately does **not** increment for writes that leave the value intact:
* OAuth2 token refreshes (the token churns; the client secret does not)
* per-environment expiry overrides
* certificate metadata re-parsing and mTLS binding changes
* renaming an environment
* BYOK rewrap — the same plaintext re-encrypted under a new tenant key
All of those move `updated_at`, which is why `updated_at` cannot be used as a
rotation signal, and a BYOK rewrap changes the stored checksum, which is why
that cannot either.
Store the `value_version` returned by your own `PUT /v1/secrets/:id/value` call
and compare it with the one here. A higher number means someone rotated the
secret outside your tooling. Creating an environment's first value produces
version 1, not 2 — creation is not a rotation.
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Secret not found |
# List Secrets
Source: https://docs.knoxcall.com/api-reference/secrets/list
Returns a paginated list of secrets with metadata only (no values)
## List Secrets
```http theme={"dark"}
GET /v1/secrets
```
Returns a paginated list of secrets with metadata only (no values).
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
| `sort` | string | `name` | Sort by: `created_at`, `name`, or `base_environment` |
| `order` | string | `asc` | Sort direction: `asc` or `desc` |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "STRIPE_API_KEY",
"shortcode_name": "STRIPE_API_KEY",
"base_environment": "production",
"secret_type": "string",
"collection_id": null,
"created_at": "2026-01-15T09:30:00.000Z",
"environment_count": 3,
"expires_at": null,
"strict_expiry_enforcement": false
}
],
"meta": {
"total": 12,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const page = await client.secrets.list({ page: 1, per_page: 20 });
const secrets = page.data;
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
page = client.secrets.list(page=1, per_page=20)
secrets = page["data"]
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl "https://api.knoxcall.com/v1/secrets?sort=name&order=asc" \
-H "Authorization: Bearer $TOKEN"
```
# Get OAuth2 Access Token
Source: https://docs.knoxcall.com/api-reference/secrets/oauth2-token
Returns the current access token for an OAuth2 secret, refreshing it automatically if expired
## Get OAuth2 Access Token
```http theme={"dark"}
GET /v1/secrets/:id/oauth2/token
```
For OAuth2-type secrets, returns the current access token. If the token has expired, KnoxCall will automatically refresh it before returning.
### Required scope
`secrets:oauth_token` (or `secrets:*`).
This endpoint returns a **live upstream credential** and refreshes it at the provider, so it is
deliberately not covered by `secrets:read`, whose scope is secret *metadata*. If you authenticate
with an OAuth access token that was deliberately narrowed, its grant must include
`secrets:oauth_token`; a token narrowed to `secrets:read` receives `403 insufficient_scope` and the
`WWW-Authenticate` header names the scope to request. API keys (`tk_`/`AKE`) and key-mirrored OAuth
clients carry no scope grant and are unaffected.
Changed 2026-08-26 — see the [changelog](/api-reference/changelog).
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The secret ID (must be an OAuth2-type secret) |
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `environment` | string | Base environment | Target a specific environment |
### Response
```json theme={"dark"}
{
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_at": "2026-03-10T15:30:00.000Z",
"token_type": "Bearer",
"connection_status": "connected"
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `invalid_type` | Secret is not an OAuth2 type |
| 403 | `insufficient_scope` | The access token's scope grant does not include `secrets:oauth_token` |
| 404 | `not_found` | Secret not found |
| 500 | `token_error` | Failed to retrieve or refresh the OAuth2 token |
# Update Secret Metadata
Source: https://docs.knoxcall.com/api-reference/secrets/update
Updates the expiry metadata on an existing secret without changing the encrypted value
## Update Secret Metadata
```http theme={"dark"}
PATCH /v1/secrets/:id
```
Updates the expiry metadata on an existing secret. Use this to set or clear `expires_at` and `strict_expiry_enforcement` without changing the encrypted value.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The secret ID |
### Request Body
At least one field is required.
| Field | Type | Description |
| - | - | - |
| `expires_at` | string \| null | ISO-8601 datetime for expiry, or `null` to clear |
| `strict_expiry_enforcement` | boolean | `true` to block injection when expired; `false` for warn-only |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "STRIPE_API_KEY",
"expires_at": "2027-01-15T00:00:00.000Z",
"strict_expiry_enforcement": true
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | No fields provided, invalid `expires_at`, or non-boolean `strict_expiry_enforcement` |
| 404 | `not_found` | Secret not found |
# Update Secret Value
Source: https://docs.knoxcall.com/api-reference/secrets/update-value
Replaces the encrypted value of an existing string secret
## Update Secret Value
```http theme={"dark"}
PUT /v1/secrets/:id/value
```
Replaces the encrypted value of an existing string secret. You can target a specific environment to set an environment-specific override.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The secret ID |
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `environment` | string | Base environment | Target a specific environment |
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `value` | string | Yes | The new secret value |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "STRIPE_API_KEY",
"environment": "production",
"value_version": 5
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
`value_version` is the environment's version **after** this write — `1` when
this call stored the environment's first value, otherwise the previous version
plus one. Because it is the version this call produced, storing it has no
read-after-write race with a rotation happening concurrently.
Compare it later with `environments[].value_version` from
[`GET /v1/secrets/:id`](/api-reference/secrets/get) to detect a rotation
performed outside your tooling. The value write and the version increment
commit in one transaction, so a rotation is never recorded without its counter
and a counter is never incremented without a rotation.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing `value` field |
| 404 | `not_found` | Secret not found |
# Error Reference
Source: https://docs.knoxcall.com/api-reference/tenant-kms/errors
Shared error codes for all Tenant KMS endpoints.
## Error reference
Tenant KMS endpoints return errors in one of two shapes. The **onboard** and **probe** failures return a human-readable message in `error` (with an optional `details` field):
```json theme={"dark"}
{ "error": "KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.", "details": "..." }
```
The **unseal** and **rotate** endpoints return a machine-readable code in `error` plus a `message`:
```json theme={"dark"}
{ "error": "not_sealed", "message": "Tenant is not currently sealed" }
```
This table is non-exhaustive — it lists the common failures. Validation errors (missing `provider`, `kms_key_ref`, malformed `config`) also return `400` with a descriptive `error` message.
### Onboard / probe failures (`POST /admin/tenant-kms`)
These return `{ error: "", details?: "" }`.
| Status | `error` message | Meaning |
| - | - | - |
| `400` | `KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.` | The probe encrypt/decrypt round-trip was denied by your KMS IAM/RBAC policy |
| `400` | `Field "" is not permitted in tenant KMS config. ...` | `config` contained a blocked static-credential field (access key, secret key, client secret, private key, service-account key, etc.) — use STS/WIF/SA-impersonation instead |
| `400` | `KMS probe roundtrip mismatch — refusing to onboard a key we cannot reliably decrypt with.` | The wrap+unwrap probe did not return the original plaintext |
| `400` | `KMS probe failed.` | The probe failed for another reason (see `details`) |
| `401` | `authentication required` | No authenticated session |
| `400` | `X-Tenant-ID header required` | `X-Tenant-ID` header missing |
| `403` | `` | Caller is not an owner/admin of the tenant |
| `500` | `Failed to persist BYOK configuration.` | The configuration probe passed but persisting it failed (see `details`) |
| `502` | `KMS provider unreachable during probe. Try again in a minute.` | KnoxCall could not reach the KMS provider during the probe |
### Step-up required (`POST /admin/tenant-kms/unseal`, `POST /admin/tenant-kms/rotate`, `POST /admin/tenant-kms/revert`)
These endpoints require a recent step-up verification. When one is missing or expired:
| Status | Response | Meaning |
| - | - | - |
| `403` | `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": , "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }` | Re-verify with a second factor within the last `max_age_minutes`, passing the response's `action_scope` as `action_scope`, and retry: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate |
### Unseal / rotate failures
These return `{ error: "", message: "" }`.
| Status | Code | Meaning |
| - | - | - |
| `404` | `no_kms_config` | No customer KMS configured for this tenant (unseal only) |
| `404` | `no_active_key` | No active master key found — required for rotation |
| `409` | `not_sealed` | Tenant is not currently sealed (unseal only) |
| `409` | `sealed` | Cannot rotate while sealed — unseal first |
| `500` | `provider_resolve_failed` | KMS provider could not be constructed from the stored config (unseal) |
| `500` | `provision_failed` | Provisioning a new tenant master key failed (rotate) |
| `502` | `unseal_failed` | Unseal roundtrip failed — KMS still unreachable or access still denied (includes a `reason` field) |
# Onboard / Configure KMS
Source: https://docs.knoxcall.com/api-reference/tenant-kms/onboard
Onboard a customer-managed KMS provider (BYOK) or replace an existing configuration via UPSERT.
## POST /admin/tenant-kms
Onboard a customer KMS provider. KnoxCall performs a live wrap + unwrap round-trip on a 32-byte test payload before committing — if the IAM/RBAC grant isn't working, the request fails immediately and nothing is stored.
Calling this endpoint when a KMS configuration already exists **replaces it** (UPSERT). The probe runs first; if it fails, the existing config is unchanged.
**Auth**: Session JWT (`Authorization: Bearer `) **plus** the `X-Tenant-ID` header, and the caller must have the **owner** or **admin** role. Requests missing `X-Tenant-ID` are rejected with `400 X-Tenant-ID header required`. No step-up required.
### Request body
**`provider`** — `aws` | `gcp` | `azure`
**`kms_key_ref`** — The provider-specific key identifier:
* **AWS**: full key ARN (`arn:aws:kms:REGION:ACCOUNT:key/KEY-ID` or `.../alias/ALIAS`)
* **GCP**: full resource path (`projects/P/locations/L/keyRings/R/cryptoKeys/K`)
* **Azure**: vault key URL (`https://VAULT.vault.azure.net/keys/KEY-NAME`)
**`config`** — Provider-specific authentication references. Static long-lived credentials (access keys, service account JSON, client secrets) are rejected at the API level.
| Provider | Required | Optional |
| - | - | - |
| `aws` | `region`, `role_arn` | `external_id` |
| `gcp` | `impersonate_service_account` | `project_id` |
| `azure` | `vault_url` | `azure_tenant_id`, `client_id`, `client_secret_env_var` |
```bash AWS KMS theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234",
"config": {
"region": "us-east-1",
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess",
"external_id": "550e8400-e29b-41d4-a716-446655440000"
}
}'
```
```bash GCP Cloud KMS theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "gcp",
"kms_key_ref": "projects/my-project/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key",
"config": {
"impersonate_service_account": "knoxcall-kms-sa@my-project.iam.gserviceaccount.com",
"project_id": "my-project"
}
}'
```
```bash Azure Key Vault theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "azure",
"kms_key_ref": "https://my-kv.vault.azure.net/keys/tenant-master-key",
"config": {
"vault_url": "https://my-kv.vault.azure.net",
"azure_tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh",
"client_id": "11112222-3333-4444-5555-666677778888",
"client_secret_env_var": "AZURE_KC_CLIENT_SECRET"
}
}'
```
### Response
```json theme={"dark"}
{
"ok": true,
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234",
"new_key_version": 1,
"new_key_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"rewrap_status": "enqueued",
"note": "A background rewrap pass will re-encrypt existing tenant data under the new key. Existing in-flight agent sessions remain valid for up to 5 minutes (BYOK session TTL)."
}
```
`rewrap_status` is always `"enqueued"` — KnoxCall unconditionally issues a background rewrap lease after onboarding.
# Revert to Operator-Managed Key
Source: https://docs.knoxcall.com/api-reference/tenant-kms/revert
Off-board BYOK: return the tenant to a KnoxCall-managed master key and remove the customer KMS configuration.
## POST /admin/tenant-kms/revert
Revert the tenant from BYOK back to a KnoxCall-managed (operator) master key. The inverse of onboarding: a new operator-wrapped master key is provisioned and activated, existing data is re-encrypted under it, and the customer KMS configuration is removed. Your KMS key itself is never modified — you can re-onboard BYOK at any time.
**Auth**: `Authorization: Bearer ` + **`X-Tenant-ID`**. Requires an owner/admin session.
This endpoint requires a **recent step-up verification** (re-authentication) performed within the last **2 minutes** by the signed-in user (the same tight window as unseal — reverting removes your KMS revocation control over data-at-rest). No step-up header or token is sent on this request — the server checks for a recent, unused verification tied to your session. To create one, re-authenticate with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. `GET /auth/step-up/status` reports unscoped verifications only, so it does not show a `tenant.kms` one. If none is present, the request returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 2, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`.
The behavior depends on whether your KMS is reachable, which the server verifies with a live wrap/unwrap probe:
### KMS reachable — lossless revert
The new operator-managed key is activated immediately and a background rewrap job re-encrypts all existing data under it. Your KMS configuration stays in place until the last row wrapped under your key has been migrated (it is needed to decrypt them), then is removed automatically.
```json theme={"dark"}
{
"reverted": true,
"path": "rewrap",
"new_key_version": 5,
"rewrap_lease_id": 42,
"note": "Re-encryption under the operator-managed key is queued. The customer KMS config will be removed automatically once it completes."
}
```
### KMS unreachable — forced revert (data loss)
If the probe fails (revoked IAM grant, deleted role trust, disabled key), secrets written while BYOK was active **cannot be re-encrypted and become permanently unreadable**. The server refuses with `409`:
```json theme={"dark"}
{
"error": "kms_unreachable_data_loss",
"probe_reason": "access_denied: KMS access denied by aws: ...",
"message": "The customer KMS is unreachable, so secrets wrapped under it cannot be re-encrypted — reverting now makes them permanently unreadable. Either restore KMS access first (then revert re-encrypts everything losslessly), or retry with { \"acknowledge_data_loss\": true } to revert anyway."
}
```
Retry with an explicit acknowledgement to proceed:
```json theme={"dark"}
{ "acknowledge_data_loss": true }
```
The response then has `"path": "forced_data_loss"` and `"rewrap_lease_id": null`. The KMS configuration is removed immediately and a snapshot of it is written to the tamper-evident audit trail — if you later restore KMS access, KnoxCall support can re-attach the configuration to recover secrets wrapped under the old key versions.
Prefer restoring KMS access before reverting. A forced revert orphans every secret value written while BYOK was active — you will need to re-enter them. Restoring access first makes the revert completely lossless.
The tenant owner receives a security notification email, and a `byok.kms.revert` entry is written to the hash-chained critical audit log.
```bash cURL theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms/revert \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{}'
```
```python Python theme={"dark"}
resp = requests.post(
"https://admin.knoxcall.com/admin/tenant-kms/revert",
headers={
"Authorization": f"Bearer {KC_ADMIN_JWT}",
"X-Tenant-ID": KC_TENANT_ID,
},
json={},
)
resp.raise_for_status()
print(resp.json()["path"]) # "rewrap"
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/revert", {
method: "POST",
headers: {
"Authorization": `Bearer ${KC_ADMIN_JWT}`,
"X-Tenant-ID": KC_TENANT_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const { reverted, path, new_key_version } = await resp.json();
```
# Rotate Master Key
Source: https://docs.knoxcall.com/api-reference/tenant-kms/rotate
Rotate the tenant master key and enqueue a background rewrap job to re-encrypt existing tenant data.
## POST /admin/tenant-kms/rotate
Rotate the tenant master key. Creates a new 32-byte master key wrapped under your customer KMS key, sets it as the active version, and retires the current active version. A background rewrap job immediately begins re-encrypting existing tenant data under the new version.
**Auth**: `Authorization: Bearer ` + **`X-Tenant-ID`**. Requires an owner/admin session.
This endpoint requires a **recent step-up verification** (re-authentication) performed within the last **5 minutes** by the signed-in user. No step-up header or token is sent on this request — the server checks for a recent, unused verification tied to your session. To create one, re-authenticate with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. `GET /auth/step-up/status` reports unscoped verifications only, so it does not show a `tenant.kms` one. If none is present, the request returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`.
Rotation is **non-reversible**. The old version becomes `retired` and cannot be made active again. Data encrypted under the old version remains decryptable so that existing tenant data can still be read while the background rewrap job completes.
Cannot be called while the tenant is sealed — unseal first.
### Response
```json theme={"dark"}
{
"rotated": true,
"new_version": 2,
"rewrap_lease_id": 42
}
```
`rewrap_lease_id` identifies the background rewrap job. This job processes 100 rows per batch at 5-second intervals, re-encrypting all existing tenant secrets under `new_version`. The old version remains available for decryption until the rewrap completes. `rewrap_lease_id` is `null` if the rewrap lease failed to issue (the rotation itself succeeded — contact support to trigger rewrap manually).
```bash cURL theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms/rotate \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
```python Python theme={"dark"}
resp = requests.post(
"https://admin.knoxcall.com/admin/tenant-kms/rotate",
headers={
"Authorization": f"Bearer {KC_ADMIN_JWT}",
"X-Tenant-ID": KC_TENANT_ID,
},
)
resp.raise_for_status()
print(resp.json()["new_version"]) # 2
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/rotate", {
method: "POST",
headers: {
"Authorization": `Bearer ${KC_ADMIN_JWT}`,
"X-Tenant-ID": KC_TENANT_ID,
},
});
const { rotated, new_version, rewrap_lease_id } = await resp.json();
```
# Get KMS Status
Source: https://docs.knoxcall.com/api-reference/tenant-kms/status
Retrieve the current BYOK KMS configuration, all key versions, seal status, and last KMS error.
## GET /admin/tenant-kms
Retrieve the current BYOK KMS configuration, all key versions, seal status, and last KMS error.
**Auth**: This is a control-plane endpoint on the admin host (e.g. `admin.knoxcall.com` / any `knoxcall.com` host). It is authenticated with a session JWT (`Authorization: Bearer `) plus the `X-Tenant-ID` header and requires an owner or admin role — not an `api.knoxcall.com` API key. No step-up required.
### Response
```json theme={"dark"}
{
"config": {
"provider": "customer_kms_aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234",
"config": {
"region": "us-east-1",
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess"
},
"sealed_since": null,
"last_verified_at": "2026-05-25T10:00:00Z",
"last_verify_error": null
},
"keys": [
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"version": 2,
"wrap_method": "customer_kms",
"status": "active",
"created_at": "2026-05-25T10:00:00Z",
"retired_at": null
},
{
"id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e",
"version": 1,
"wrap_method": "customer_kms",
"status": "retired",
"created_at": "2026-01-01T00:00:00Z",
"retired_at": "2026-05-25T10:00:00Z"
}
]
}
```
`config.provider` in the GET response uses the internal format (`customer_kms_aws`, `customer_kms_gcp`, `customer_kms_azure`) — this is different from the short input form (`aws`, `gcp`, `azure`) used in the onboard request.
If no customer KMS is configured for this tenant, the endpoint still returns `200` with `config: null` (and `keys: []`).
```bash cURL theme={"dark"}
curl https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
```python Python theme={"dark"}
import requests
resp = requests.get(
"https://admin.knoxcall.com/admin/tenant-kms",
headers={
"Authorization": f"Bearer {KC_ADMIN_JWT}",
"X-Tenant-ID": KC_TENANT_ID,
},
)
resp.raise_for_status()
data = resp.json()
sealed = data["config"]["sealed_since"] is not None
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms", {
headers: {
"Authorization": `Bearer ${KC_ADMIN_JWT}`,
"X-Tenant-ID": KC_TENANT_ID,
},
});
const { config, keys } = await resp.json();
const isSealed = config.sealed_since !== null;
```
# Unseal
Source: https://docs.knoxcall.com/api-reference/tenant-kms/unseal
Force an immediate unseal attempt after restoring KMS access.
## POST /admin/tenant-kms/unseal
Force an immediate unseal attempt after restoring KMS access. This is **not** idempotent — once the tenant is unsealed, calling it again returns `409 not_sealed`.
**Auth**: served on the admin host with a session `Authorization: Bearer ` plus the **`X-Tenant-ID`** header (owner/admin role). This action also requires a recent **step-up verification** within a **2-minute** window — enforced server-side against the authenticated user. No step-up header or token is sent on this request; instead the signed-in user must have re-authenticated shortly beforehand with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. If no recent verification exists, the endpoint returns `403 { "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 2, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`. Omitting `X-Tenant-ID` returns `400`.
Manual unseal forces immediate recovery rather than waiting for the next BYOK bundle-renewal cycle.
### Response
```json theme={"dark"}
{ "unsealed": true }
```
### Error codes
| Status | Code | Meaning |
| - | - | - |
| `404` | `no_kms_config` | No customer KMS configured for this tenant |
| `409` | `not_sealed` | Tenant is not currently sealed |
| `502` | `unseal_failed` | Unseal attempt failed — KMS still unreachable or access still denied |
`unseal_failed` includes a `reason` field: `"access_denied"` (IAM grant still missing) or `"unreachable"` (network issue).
```bash cURL theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms/unseal \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
```python Python theme={"dark"}
resp = requests.post(
"https://admin.knoxcall.com/admin/tenant-kms/unseal",
headers={
"Authorization": f"Bearer {KC_ADMIN_JWT}",
"X-Tenant-ID": KC_TENANT_ID,
},
)
resp.raise_for_status()
assert resp.json()["unsealed"] is True
```
```javascript Node.js theme={"dark"}
const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/unseal", {
method: "POST",
headers: {
"Authorization": `Bearer ${KC_ADMIN_JWT}`,
"X-Tenant-ID": KC_TENANT_ID,
},
});
const { unsealed } = await resp.json();
```
# Versioning
Source: https://docs.knoxcall.com/api-reference/versioning
How KnoxCall versions its API: the /v1 path major plus the dated KnoxCall-Version header minor, what counts as a breaking change, and how to pin.
# Versioning
KnoxCall versions the API on two axes:
* **Major** — the URL path (`/v1`). It only changes for a wholesale redesign, and old majors
keep working.
* **Minor** — a **dated revision** selected with the `KnoxCall-Version` request header
(`YYYY-MM-DD`). This is the same model Stripe (`Stripe-Version`) and Anthropic
(`anthropic-version`) use.
```bash theme={"dark"}
curl https://api.knoxcall.com/v1/routes \
-H "Authorization: Bearer $KNOXCALL_ACCESS_TOKEN" \
-H "KnoxCall-Version: 2026-08-05"
```
The current version is **`2026-08-05`**. Every response echoes the version that served it as a
`KnoxCall-Version` response header.
## Pinning
If you send no `KnoxCall-Version` header, the request runs against the **newest** version. To
insulate your integration from future breaking changes, **pin** the header to a dated version;
the official SDKs do this automatically, sending the version they were built against.
An unknown or malformed `KnoxCall-Version` is rejected:
```json theme={"dark"}
{
"error": {
"type": "invalid_api_version",
"message": "Unknown KnoxCall-Version \"2099-12-31\". Supported versions: 2026-08-05.",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
## What is (and isn't) a breaking change
We add fields, enum values, and endpoints **without** a new version — these are **not**
breaking, and your client must tolerate them:
* **Tolerate unknown fields.** Don't fail if a response grows a new property.
* **Tolerate new enum values** on fields you don't strictly switch over.
* **Don't depend on property order** or on the absence of a field.
A change that could break a conforming client — removing or renaming a field, changing a type,
changing a status code, tightening validation — ships under a **new dated version**. Your
pinned version keeps the old behaviour; you migrate on your own schedule by bumping the header.
## Changelog
Dated API changes are recorded in the [Changelog](/api-reference/changelog). Watch it for new
versions and the migration notes that accompany any breaking change.
# Create Webhook
Source: https://docs.knoxcall.com/api-reference/webhooks/create
Create a new webhook to receive real-time event notifications
## Create Webhook
```text theme={"dark"}
POST /v1/webhooks
```
Creates a new webhook. The `secret_key` for verifying webhook signatures is returned **only once** in the response.
### Request Body
| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the webhook |
| `url` | string | Yes | The URL to deliver webhook payloads to |
| `event_types` | string\[] | Yes | Events to subscribe to (non-empty array). Supported: `request.received`, `request.success`, `request.redirect`, `request.client_error`, `request.server_error`, `request.timeout`, `request.error`, `request.completed`, `audit.event`. See [List Event Types](/api-reference/webhooks/event-types). Subscribing to `audit.event` needs `audit_log:list` on a key, or an Admin or Owner behind a user-bound token (otherwise `403 forbidden`); an OAuth token narrowed by scope also needs `audit-logs:read` (otherwise `403 insufficient_scope`). The same applies to any later update of a webhook that stays subscribed, including a rename. |
| `description` | string | No | Description |
| `method` | string | No | HTTP method for delivery (defaults to `POST`) |
| `auth_type` | string | No | Authentication type: `none`, `basic`, `bearer`, `hmac`, `header` (defaults to `none`) |
| `auth_config` | object | No | Auth credentials (depends on `auth_type`) |
| `request_headers` | object | No | Custom headers to include in deliveries |
| `route_filter` | string\[] | No | Only trigger for events on these route IDs (array of UUIDs) |
| `include_request_body` | boolean | No | Include the original request body in the payload (defaults to `true`) |
| `include_response_body` | boolean | No | Include the upstream response body (defaults to `false`) |
| `include_headers` | boolean | No | Include request/response headers (defaults to `false`) |
| `timeout_seconds` | integer | No | Delivery timeout in seconds (defaults to `30`) |
| `retry_on_failure` | boolean | No | Retry failed deliveries (defaults to `true`) |
| `max_retries` | integer | No | Maximum retry attempts (defaults to `3`) |
| `enabled` | boolean | No | Enable immediately (defaults to `true`) |
| `hmac_key_id` | uuid | No | Crypto Keys transit key ID to use for HMAC signing (stage 2.5; replaces the `secret_key`-based default signing) |
| `hmac_format` | string | No | Signature format when using `hmac_key_id`: `legacy`, `stripe`, `github`, `slack`, `aws-sns`, `custom` |
| `hmac_header_name` | string | No | Custom header name when `hmac_format` is `custom` |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Error Notifications",
"description": "Notify Slack on 5xx errors",
"url": "https://hooks.slack.com/services/T00/B00/xxx",
"method": "POST",
"event_types": ["request.error", "request.timeout"],
"auth_type": "none",
"enabled": true,
"hmac_key_id": null,
"hmac_format": null,
"hmac_header_name": null,
"created_at": "2026-01-20T10:00:00.000Z",
"secret_key": "3f9c1a4e8b2d6075e1c93af0d4b78652a9f1e0c3b6d8275a4e1f93c0b7a6d2e7"
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
The `secret_key` is returned **only at creation time**. Store it securely -- you'll need it to verify webhook signatures. See the [Verifying Signatures](/webhooks/verifying-signatures) guide.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const webhook = await client.webhooks.create({
name: "Error Notifications",
url: "https://hooks.slack.com/services/T00/B00/xxx",
event_types: ["request.error", "request.timeout"],
description: "Notify Slack on 5xx errors"
});
console.log(`Store webhook secret: ${webhook.secret_key}`);
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
webhook = client.webhooks.create(
name="Error Notifications",
url="https://hooks.slack.com/services/T00/B00/xxx",
event_types=["request.error", "request.timeout"],
description="Notify Slack on 5xx errors"
)
print(f"Store webhook secret: {webhook['secret_key']}")
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Error Notifications",
"url": "https://hooks.slack.com/services/T00/B00/xxx",
"event_types": ["request.error", "request.timeout"],
"description": "Notify Slack on 5xx errors"
}'
```
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | Missing required fields or empty `event_types` array |
# Delete Webhook
Source: https://docs.knoxcall.com/api-reference/webhooks/delete
Permanently delete a webhook and all of its delivery logs
## Delete Webhook
```text theme={"dark"}
DELETE /v1/webhooks/:id
```
Permanently deletes a webhook and all of its delivery logs.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The webhook ID |
### Response
```json theme={"dark"}
{
"data": { "deleted": true },
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Webhook not found |
# List Event Types
Source: https://docs.knoxcall.com/api-reference/webhooks/event-types
List the webhook event types that can be subscribed to
## List Event Types
```text theme={"dark"}
GET /v1/webhooks/event-types
```
Returns the set of event types you can subscribe to when creating a webhook. Useful for
building event pickers in UIs and connectors (Zapier, Make, n8n use this to populate their
event dropdowns).
### Response
```json theme={"dark"}
{
"data": {
"event_types": [
{ "value": "request.received", "label": "Request > Received", "description": "Triggered when a request is received" },
{ "value": "request.success", "label": "Request > Success", "description": "Triggered on a successful 2xx response" },
{ "value": "request.redirect", "label": "Request > Redirect", "description": "Triggered on a 3xx redirect response" },
{ "value": "request.client_error", "label": "Request > Client_error", "description": "Triggered on a 4xx client error" },
{ "value": "request.server_error", "label": "Request > Server_error", "description": "Triggered on a 5xx server error" },
{ "value": "request.timeout", "label": "Request > Timeout", "description": "Triggered when the request times out" },
{ "value": "request.error", "label": "Request > Error", "description": "Triggered on any error (non-2xx response)" },
{ "value": "request.completed", "label": "Request > Completed", "description": "Triggered on every completed request" },
{ "value": "audit.event", "label": "Audit > Event", "description": "Triggered on any audit-log entry (config changes, auth events) — for SIEM / observability streams" }
]
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
The `audit.event` type streams every audit-log entry (config changes, auth events,
rotations) to your webhook — ideal for SIEM/observability pipelines. Unlike the
`request.*` events, it fires in both Live and Test modes. The same stream also carries a
record of proxied requests, and that record is delivered only in the request's own mode,
like the `request.*` events.
Each `audit.event` delivery carries the entry's **sanitised** `details`. Credential-shaped
values and URL userinfo are removed before it is sent and before it is stored in your delivery
log. Because the stream carries change details, creating or changing a webhook subscribed to
`audit.event` needs what reading them needs: an Admin or Owner in the dashboard or on a
user-bound token, or `audit_log:list` on an API key or OAuth client. Otherwise the request is
refused with `403`. That covers any change to a webhook that stays subscribed, including a rename
or pausing it; deleting it is not affected. An OAuth token narrowed by scope must also hold
`audit-logs:read`, or it is refused with `403 insufficient_scope`.
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const { event_types } = await client.webhooks.listEventTypes();
console.log(event_types.map((e) => e.value));
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
data = client.webhooks.list_event_types()
print([e["value"] for e in data["event_types"]])
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/webhooks/event-types \
-H "Authorization: Bearer $TOKEN"
```
# Get Webhook
Source: https://docs.knoxcall.com/api-reference/webhooks/get
Returns the full configuration for a single webhook, including delivery statistics
## Get Webhook
```text theme={"dark"}
GET /v1/webhooks/:id
```
Returns the full configuration for a single webhook, including delivery statistics.
Credential material is never returned. `secret_key`, `auth_config` and
`request_headers` are all accepted on create and update, and all omitted from
this response. `request_headers` counts as credential material because KnoxCall
spreads those headers into the outbound delivery request alongside the
`Authorization` header derived from `auth_config` — a value stored there is
indistinguishable from a destination API key. To change a header, send the new
value on `PATCH /v1/webhooks/:id`; to stop sending one, send a
`request_headers` object without it.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The webhook ID |
### Response
```json theme={"dark"}
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Error Notifications",
"description": "Notify Slack on 5xx errors",
"url": "https://hooks.slack.com/services/T00/B00/xxx",
"method": "POST",
"event_types": ["request.error", "request.timeout"],
"auth_type": "none",
"route_filter": null,
"include_request_body": true,
"include_response_body": false,
"include_headers": false,
"timeout_seconds": 30,
"retry_on_failure": true,
"max_retries": 3,
"enabled": true,
"last_triggered_at": "2026-03-10T14:22:01.000Z",
"last_success_at": "2026-03-10T14:22:01.000Z",
"last_failure_at": "2026-02-28T08:15:00.000Z",
"trigger_count": 142,
"success_count": 140,
"failure_count": 2,
"created_at": "2026-01-20T10:00:00.000Z"
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Webhook not found |
# List Webhooks
Source: https://docs.knoxcall.com/api-reference/webhooks/list
Returns all webhooks configured in your tenant
## List Webhooks
```text theme={"dark"}
GET /v1/webhooks
```
Returns all webhooks in your tenant.
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
| `sort` | string | `created_at` | Sort by: `created_at`, `name`, or `enabled` |
| `order` | string | `desc` | Sort direction: `asc` or `desc` |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Error Notifications",
"description": "Notify Slack on 5xx errors",
"url": "https://hooks.slack.com/services/T00/B00/xxx",
"method": "POST",
"event_types": ["request.error", "request.timeout"],
"auth_type": "none",
"enabled": true,
"last_triggered_at": "2026-03-10T14:22:01.000Z",
"trigger_count": 142,
"success_count": 140,
"failure_count": 2,
"created_at": "2026-01-20T10:00:00.000Z"
}
],
"meta": {
"total": 5,
"page": 1,
"per_page": 20,
"total_pages": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const page = await client.webhooks.list({ page: 1, per_page: 20 });
const webhooks = page.data;
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
page = client.webhooks.list(page=1, per_page=20)
webhooks = page["data"]
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl https://api.knoxcall.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN"
```
### Errors
| Status | Type | Description |
| - | - | - |
| 403 | `forbidden` | The API key's policy does not grant `read` on `webhook` |
| 500 | `internal_error` | Failed to list webhooks |
# Get Webhook Logs
Source: https://docs.knoxcall.com/api-reference/webhooks/logs
Returns paginated delivery logs for a specific webhook
## Get Webhook Logs
```text theme={"dark"}
GET /v1/webhooks/:id/logs
```
Returns paginated delivery logs for a specific webhook, showing every delivery attempt with status and timing.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The webhook ID |
### Query Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `page` | integer | `1` | Page number |
| `per_page` | integer | `20` | Items per page (max 100) |
### Response
```json theme={"dark"}
{
"data": [
{
"id": "log-uuid",
"http_method": "POST",
"target_url": "https://hooks.example.com/services/T00/B00/xxx",
"source_ip": null,
"response_status": 200,
"response_time_ms": 342,
"executed_at": "2026-03-10T14:22:01.000Z",
"success": true,
"error_message": null
}
],
"meta": {
"total": 142,
"page": 1,
"per_page": 20,
"total_pages": 8,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | Webhook not found |
# Test Webhook
Source: https://docs.knoxcall.com/api-reference/webhooks/test
Fire a sample event at a webhook and report the delivery result
## Test Webhook
```text theme={"dark"}
POST /v1/webhooks/{id}/test
```
Sends a synthetic `webhook.test` event to the webhook's configured URL and returns the
delivery result (HTTP status and latency). Use it to confirm a webhook endpoint is
reachable and that signature verification works before relying on it.
The test delivery goes through the **same hardened path as real events**: the
resolve-and-pin SSRF egress (a test cannot reach a private/internal address) and the same
HMAC signing. The attempt is recorded in the webhook's delivery logs. Retries are disabled
for the test so you get an immediate result.
### Response
```json theme={"dark"}
{
"data": {
"success": true,
"status": 200,
"response_time_ms": 142
},
"meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" }
}
```
When delivery fails, `success` is `false` and `error` describes why:
```json theme={"dark"}
{
"data": { "success": false, "response_time_ms": 5012, "error": "Request timeout" },
"meta": { "request_id": "..." }
}
```
```typescript Node.js theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
const result = await client.webhooks.test("a1b2c3d4-...");
console.log(result.success ? `delivered in ${result.response_time_ms}ms` : result.error);
```
```python Python theme={"dark"}
from knoxcall import KnoxCall
client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
result = client.webhooks.test("a1b2c3d4-...")
print(f"delivered in {result['response_time_ms']}ms" if result["success"] else result["error"])
```
```bash cURL theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/webhooks/a1b2c3d4-.../test \
-H "Authorization: Bearer $TOKEN"
```
### Errors
| Status | Type | Description |
| - | - | - |
| 404 | `not_found` | No webhook with that ID in the current mode (Live/Sandbox) |
| 403 | `permission_denied` | Tenant policy denies the `test` action on `webhook` |
# Update Webhook
Source: https://docs.knoxcall.com/api-reference/webhooks/update
Update one or more fields on an existing webhook
## Update Webhook
```text theme={"dark"}
PATCH /v1/webhooks/:id
```
Updates one or more fields on an existing webhook.
### Path Parameters
| Parameter | Type | Description |
| - | - | - |
| `id` | uuid | The webhook ID |
### Request Body
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
| - | - | - |
| `name` | string | Display name |
| `description` | string | Description |
| `url` | string | Delivery URL |
| `method` | string | HTTP method |
| `event_types` | string\[] | Subscribed event types |
| `auth_type` | string | Authentication type: `none`, `basic`, `bearer`, `hmac`, `header` |
| `auth_config` | object | Authentication credentials |
| `request_headers` | object | Custom delivery headers |
| `route_filter` | string\[] | Array of route UUIDs to filter events by |
| `include_request_body` | boolean | Include request body |
| `include_response_body` | boolean | Include response body |
| `include_headers` | boolean | Include headers |
| `timeout_seconds` | integer | Delivery timeout |
| `retry_on_failure` | boolean | Retry on failure |
| `max_retries` | integer | Max retry attempts |
| `enabled` | boolean | Enable or disable |
| `hmac_key_id` | uuid | Crypto Keys transit key for HMAC signing |
| `hmac_format` | string | Signature format (`legacy`, `stripe`, `github`, `slack`, `aws-sns`, `custom`) |
| `hmac_header_name` | string | Custom header name when `hmac_format` is `custom` |
### Response
Returns `{ "data": { ... }, "meta": { ... } }` — the updated webhook fields: `id`, `name`, `description`, `url`, `method`, `event_types`, `auth_type`, `enabled`, `created_at`. The `secret_key` is never returned after creation.
### Errors
| Status | Type | Description |
| - | - | - |
| 400 | `validation_error` | No valid fields provided |
| 404 | `not_found` | Webhook not found |
# AWS (Lambda, ECS, EC2, IRSA)
Source: https://docs.knoxcall.com/api-reference/workload-identity/aws
Authenticate from AWS workloads to KnoxCall using IAM Roles for Service Accounts (IRSA) or static client credentials
# AWS Workload Identity
KnoxCall accepts AWS-issued OIDC tokens from EKS pods (IRSA) and other workloads with `AWS_WEB_IDENTITY_TOKEN_FILE` configured. STS workload identity for ECS / Lambda is supported via the same pattern.
## 1. Set up the IAM role + EKS service account (IRSA)
Existing AWS IAM setup — your cluster's OIDC provider must be registered. See [AWS docs](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html).
Once configured, your pod has these env vars automatically:
* `AWS_WEB_IDENTITY_TOKEN_FILE` (path to a file containing a signed JWT)
* `AWS_ROLE_ARN`
## 2. Configure the KnoxCall binding
Create the workload binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes **for this action**: it must carry
`"action_scope": "oauth.client"`. A binding is a trust that lets an external workload
mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl`
carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the
same session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn
step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up`
for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then
`POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request
inside the 5-minute window. A verification made for a different action, such as one a
Dashboard prompt raised, carries that action's scope and does not satisfy this gate.
Each verification is single-use, so a retried request needs a fresh one. Listing and
revoking bindings need no verification: containment must never be gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "aws-prod-eks",
"issuer": "https://sts.amazonaws.com",
"audience": "knoxcall:api",
"attribute_conditions": {
"arn": "arn:aws:iam::123456789012:role/knoxcall-prod"
},
"allowed_scopes": ["routes:write"],
"access_token_ttl_seconds": 3600
}'
```
Lock attribute conditions to the assumed IAM role ARN. The OIDC token from `sts.amazonaws.com` carries the role ARN in the `arn` claim.
## 3. Call KnoxCall
```javascript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: "acme" });
await client.routes.list();
```
The SDK reads `AWS_WEB_IDENTITY_TOKEN_FILE`, exchanges the token for a KnoxCall access token, and uses it. No secrets stored.
## Lambda / EC2 without IRSA — static client credentials
If you're on Lambda or EC2 without IRSA, the SDK falls back to plain `client_credentials`: the static `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` env vars. There is no IMDSv2 / EC2 instance-identity path — the SDK only reads `AWS_WEB_IDENTITY_TOKEN_FILE` for the OIDC flow and otherwise uses these env vars. Store them in AWS Secrets Manager or Lambda environment variables encrypted with a KMS key.
For Lambda with IRSA via container images, the IRSA env vars are set automatically by the Lambda runtime — same SDK works.
## Troubleshooting
* **`Could not detect KnoxCall credentials`** — `AWS_WEB_IDENTITY_TOKEN_FILE` is unset. Verify the pod's service account is annotated with `eks.amazonaws.com/role-arn`.
* **`invalid_grant: no binding matched`** — decode the JWT (`cat $AWS_WEB_IDENTITY_TOKEN_FILE | cut -d. -f2 | base64 -d`) and compare its `arn` claim to your binding.
# Azure (Functions, App Service, AKS, VMs)
Source: https://docs.knoxcall.com/api-reference/workload-identity/azure
Authenticate from Azure workloads to KnoxCall using Managed Identity
# Azure Workload Identity
Azure workloads with a system-assigned or user-assigned Managed Identity get a local metadata endpoint that issues short-lived tokens. KnoxCall accepts these via RFC 8693.
## 1. Enable Managed Identity on your resource
Azure portal: **Identity → System assigned: On** (or attach a user-assigned identity).
This sets the env vars on every request:
* `IDENTITY_ENDPOINT` — local metadata URL
* `IDENTITY_HEADER` — header value the metadata service requires
## 2. Configure the trust binding
Create the binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API on the admin host (`admin.knoxcall.com`). The admin API is authenticated by your dashboard session JWT plus an `X-Tenant-ID` header (it is **not** the API-key data plane on `api.knoxcall.com`).
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes **for this action**: it must carry
`"action_scope": "oauth.client"`. A binding is a trust that lets an external workload
mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl`
carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the
same session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn
step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up`
for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then
`POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request
inside the 5-minute window. A verification made for a different action, such as one a
Dashboard prompt raised, carries that action's scope and does not satisfy this gate.
Each verification is single-use, so a retried request needs a fresh one. Listing and
revoking bindings need no verification: containment must never be gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "azure-prod-functions",
"issuer": "https://login.microsoftonline.com",
"audience": "knoxcall:api",
"attribute_conditions": {
"oid": ""
},
"allowed_scopes": ["routes:read"],
"access_token_ttl_seconds": 3600
}'
```
Lock to the Managed Identity object id (`oid` claim). You can also lock `tid` (tenant) or `appid` for tighter control.
## 3. Call KnoxCall
```javascript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: "acme" });
await client.routes.list();
```
The SDK calls `$IDENTITY_ENDPOINT?resource=knoxcall:api` with the `X-IDENTITY-HEADER`, gets the JWT, exchanges it for a KnoxCall token.
## Troubleshooting
* **`Could not detect KnoxCall credentials`** — Managed Identity isn't enabled or env vars aren't propagated. Check the resource's Identity blade.
* **`invalid_target`** — `resource` param in the metadata call must be exactly `knoxcall:api`; we set this automatically.
* **`invalid_grant: no binding matched`** — verify the `oid` of your Managed Identity (run `az identity show --ids --query principalId`) matches the binding.
# Google Cloud (GCE, GKE, Cloud Run, Cloud Functions)
Source: https://docs.knoxcall.com/api-reference/workload-identity/gcp
Authenticate from any GCP workload to KnoxCall using the GCE metadata service
# Google Cloud Workload Identity
Every Google Cloud workload has a metadata service at `metadata.google.internal` that issues short-lived OIDC tokens signed by Google. KnoxCall accepts these tokens via RFC 8693 token exchange.
## 1. Configure the trust binding
Create the workload binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes **for this action**: it must carry
`"action_scope": "oauth.client"`. A binding is a trust that lets an external workload
mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl`
carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the
same session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn
step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up`
for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then
`POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request
inside the 5-minute window. A verification made for a different action, such as one a
Dashboard prompt raised, carries that action's scope and does not satisfy this gate.
Each verification is single-use, so a retried request needs a fresh one. Listing and
revoking bindings need no verification: containment must never be gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "gcp-prod-api",
"issuer": "https://accounts.google.com",
"audience": "knoxcall:api",
"attribute_conditions": {
"email": "knoxcall-prod@my-project.iam.gserviceaccount.com"
},
"allowed_scopes": ["routes:write", "secrets:read"],
"access_token_ttl_seconds": 3600
}'
```
Lock to the service account `email` claim, or to its numeric `sub`. A rule must pin one of the two: `https://accounts.google.com` signs tokens for every Google account, so a rule without an identifying claim would match other projects' service accounts, and KnoxCall refuses it with a `400`.
Conditions compare **top-level claims by exact string match**. Two conditions that look reasonable do not work today, so do not add them:
* `email_verified` — Google sends it as a boolean, and a condition value is a string, so the rule would never match.
* `google.compute_engine.project_id` / `instance_id` — these sit inside a nested `google` object, and a dotted key is not a path. Instance-level binding is not supported yet; use one service account per workload you want to tell apart.
## 2. Run with the right service account
### Cloud Run / Cloud Functions
```bash theme={"dark"}
gcloud run deploy my-service \
--image gcr.io/my-project/my-service \
--service-account knoxcall-prod@my-project.iam.gserviceaccount.com \
--set-env-vars KNOXCALL_TENANT=acme
```
### GKE Workload Identity
```yaml theme={"dark"}
apiVersion: v1
kind: ServiceAccount
metadata:
name: knoxcall-caller
annotations:
iam.gke.io/gcp-service-account: knoxcall-prod@my-project.iam.gserviceaccount.com
```
```yaml theme={"dark"}
spec:
template:
spec:
serviceAccountName: knoxcall-caller
containers:
- env:
- { name: KNOXCALL_TENANT, value: acme }
```
### GCE
The VM's default or attached service account is used automatically.
## 3. Call KnoxCall
```javascript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT });
const routes = await client.routes.list();
```
The SDK hits `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=knoxcall:api`, fetches the signed token, and exchanges it for a KnoxCall access token.
## Troubleshooting
* **`Could not detect KnoxCall credentials`** — the metadata service isn't reachable. This is normal locally; set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` for local dev.
* **`invalid_grant: no binding matched`** — verify the service account email matches what's in the binding. Run `gcloud auth print-identity-token --audiences=knoxcall:api` on the host and decode it.
* **Network egress** — if your GKE pod has restricted egress, allow `metadata.google.internal:80`.
# GitHub Actions
Source: https://docs.knoxcall.com/api-reference/workload-identity/github-actions
Authenticate from GitHub Actions to KnoxCall with zero stored secrets via OIDC workload identity federation
# GitHub Actions Workload Identity
GitHub Actions can exchange its workflow OIDC token for a KnoxCall access token via RFC 8693 — no secrets stored in the repo, organization, or environment.
## 1. Add the workflow permission
In your `.github/workflows/*.yml`:
```yaml theme={"dark"}
permissions:
id-token: write # required to mint the GHA OIDC token
contents: read # standard
```
## 2. Configure the trust binding in KnoxCall
In the dashboard, open **Settings → API → Workload Identity → Connect workload** — the wizard checks your issuer, builds the rule, and lets you test a real token before your first run — or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes **for this action**: it must carry
`"action_scope": "oauth.client"`. A binding is a trust that lets an external workload
mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl`
carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the
same session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn
step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up`
for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then
`POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request
inside the 5-minute window. A verification made for a different action, such as one a
Dashboard prompt raised, carries that action's scope and does not satisfy this gate.
Each verification is single-use, so a retried request needs a fresh one. Listing and
revoking bindings need no verification: containment must never be gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "gha-acme-api-deploy",
"issuer": "https://token.actions.githubusercontent.com",
"audience": "knoxcall:api",
"attribute_conditions": {
"repository": "acme/api",
"ref": "refs/heads/main",
"workflow": "deploy"
},
"allowed_scopes": ["routes:write", "secrets:read"],
"access_token_ttl_seconds": 3600
}'
```
**Attribute conditions are required.** They prevent any caller on `token.actions.githubusercontent.com` (i.e., any GitHub repository in the world) from minting tokens for your tenant. Always lock down at minimum `repository`; lock to `workflow` + `ref` for production paths.
**`allowed_scopes` is required and may not be empty.** A binding with no scopes is not a binding that grants nothing — a token with an empty scope is treated as *unnarrowed*, so it would reach every endpoint your tenant has. Creating one is refused with `400 invalid_allowed_scopes`, and a binding that somehow holds an empty list refuses to exchange. List exactly the scopes the workflow needs.
## 3. Call KnoxCall from the workflow
```yaml theme={"dark"}
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install @knoxcall/sdk@^1.0.0
- run: node deploy.js
env:
KNOXCALL_TENANT: acme
```
In `deploy.js`:
```javascript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT });
const routes = await client.routes.list();
```
The SDK auto-detects `ACTIONS_ID_TOKEN_REQUEST_URL` + `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, fetches the OIDC token, exchanges it for a 1-hour KnoxCall access token, and uses it for all API calls. Zero stored secrets anywhere in your repo or organization.
## What's in the OIDC token
GitHub puts these claims in every workflow OIDC token; you can match on any of them in `attribute_conditions`:
| Claim | Example | Use for |
| - | - | - |
| `repository` | `acme/api` | Always include — locks the binding to one repo |
| `repository_owner` | `acme` | Permit any repo under an org |
| `ref` | `refs/heads/main` | Permit production branch only |
| `ref_type` | `branch` | Block tag pushes |
| `event_name` | `push` / `pull_request` / `workflow_dispatch` | Block PRs from forks |
| `workflow` | `deploy` | Permit one workflow only |
| `job_workflow_ref` | `acme/api/.github/workflows/deploy.yml@refs/heads/main` | Strictest match |
| `environment` | `production` | Permit only protected environments |
| `sub` | `repo:acme/api:ref:refs/heads/main` | Composite — useful when you want exact match |
## Recommendations
* For production paths: require `environment` + `ref` + `repository`. GitHub Environments add a manual-approval gate you should leverage.
* For PR / CI paths: require `event_name != pull_request` to avoid third-party fork PRs minting tokens.
* Use one binding per logical workload, not one per repo. Granular bindings make `last_used_at` data actually useful.
## Troubleshooting
* **`invalid_grant: no binding matched`** — JWT attributes don't match. Fetch and decode the OIDC token to inspect its claims and compare them to your binding:
```bash theme={"dark"}
curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=knoxcall:api" \
| python3 -c 'import sys,json,base64; t=json.load(sys.stdin)["value"].split("."); print(base64.urlsafe_b64decode(t[1]+"=="*(-len(t[1])%4)).decode())'
```
* **`invalid_request: subject_token signature invalid`** — Audience mismatch. The SDK appends `&audience=knoxcall:api` to the OIDC request — make sure your binding's `audience` field is exactly `knoxcall:api`.
* **`invalid_request: subject_token expired`** — Clock skew. Ensure your runner's clock is sane (NTP).
# HCP Terraform
Source: https://docs.knoxcall.com/api-reference/workload-identity/hcp-terraform
Authenticate a Terraform run to KnoxCall with zero stored secrets, using HCP Terraform workload identity and RFC 8693 token exchange
# HCP Terraform Workload Identity
An HCP Terraform run can exchange its own workload identity token for a short-lived KnoxCall access token — no KnoxCall API key stored as a workspace variable, and nothing to rotate.
HCP Terraform is one of the three issuers KnoxCall knows by name, so a binding here is matched on `sub` rather than falling back to a generic rule. `sub` carries the organisation, project, workspace **and run phase**, which is what makes the strongest recommendation on this page possible: a `plan` can be given different access from an `apply`.
Unlike GitHub Actions, Google Cloud or AWS, the KnoxCall SDKs do **not** auto-detect an HCP
Terraform run — the token is handed to you in an environment variable, so you pass it to the
exchange yourself. Everything on this page uses that variable directly.
## 1. Turn on workload identity in the workspace
Set a workspace variable — **Workspace → Variables → Environment variable**:
| Variable | Value |
| - | - |
| `TFC_WORKLOAD_IDENTITY_AUDIENCE` | `knoxcall:api` |
Once it is set, every plan and apply gets a `TFC_WORKLOAD_IDENTITY_TOKEN` variable in its run environment containing a signed OIDC token for that audience.
If the workspace already uses workload identity for a cloud provider, do not overwrite the existing audience — add a second one instead. `TFC_WORKLOAD_IDENTITY_AUDIENCE_KNOXCALL` produces `TFC_WORKLOAD_IDENTITY_TOKEN_KNOXCALL` alongside it, and the tag may contain only letters, numbers and underscores.
## 2. Register the binding in KnoxCall
Dashboard → **Settings → API → Workload Identity**, or call the admin API. The `/admin/*` routes are served on the admin host and authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count), made
within the last 5 minutes with `"action_scope": "oauth.client"`: a binding is a trust that
lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth
client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same
session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}` (or a WebAuthn step-up with the
same scope: `POST /auth/passkey/verify-step-up`, or `POST /auth/2fa/webauthn/verify-step-up`
for a security key), then replay inside the window. A verification made for another action,
such as one a Dashboard prompt raised, does not satisfy this gate. Each verification is
single-use. Listing and revoking bindings need no verification; containment is never gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "tfc-platform-prod-apply",
"issuer": "https://app.terraform.io",
"audience": "knoxcall:api",
"attribute_conditions": {
"sub": "organization:acme:project:platform:workspace:prod:run_phase:apply"
},
"allowed_scopes": ["routes:write", "secrets:read"],
"access_token_ttl_seconds": 3600
}'
```
**`attribute_conditions` is required**, and for this issuer it must pin `sub`. Without it the binding would describe every workspace in every organisation on `app.terraform.io`, not yours.
**`allowed_scopes` is required and may not be empty.** An empty allow-list is not "no access" — a token with no scopes is treated as *unnarrowed*, so it would reach every endpoint your tenant has. Creating one is refused with `400 invalid_allowed_scopes`.
## 3. Exchange the token inside the run
```hcl theme={"dark"}
data "http" "knoxcall_token" {
url = "https://api.knoxcall.com/oauth/token"
method = "POST"
request_headers = {
Content-Type = "application/x-www-form-urlencoded"
}
request_body = join("&", [
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token_type=urn:ietf:params:oauth:token-type:id_token",
"subject_token=${urlencode(var.tfc_workload_identity_token)}",
])
}
```
…with the token passed in as a variable:
```hcl theme={"dark"}
variable "tfc_workload_identity_token" {
type = string
sensitive = true
# Populated from the run environment: TF_VAR_tfc_workload_identity_token,
# or read the TFC_WORKLOAD_IDENTITY_TOKEN environment variable in a wrapper.
}
```
Or, from a `local-exec` / CI wrapper around the run:
```bash theme={"dark"}
KNOX_TOKEN=$(curl -sS https://api.knoxcall.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d subject_token="$TFC_WORKLOAD_IDENTITY_TOKEN" | jq -r .access_token)
```
The response is a standard OAuth token response; `access_token` is a `kc_` token valid for the binding's TTL, capped at twice the remaining life of the Terraform token.
## What's in the token
| Claim | Example | Use for |
| - | - | - |
| `sub` | `organization:acme:project:platform:workspace:prod:run_phase:apply` | **Required.** Organisation, project, workspace and phase in one string |
| `terraform_organization_name` | `acme` | Readability in a rule that also pins `sub` |
| `terraform_project_name` | `platform` | As above |
| `terraform_workspace_name` | `prod` | As above |
| `terraform_full_workspace` | `organization:acme:project:platform:workspace:prod` | The workspace without the run phase |
| `terraform_run_id` | `run-CZcmD7eagjhyX0vN` | Never pin this — it changes every run |
| `terraform_run_phase` | `apply` | `plan` or `apply` |
A module test run carries a different `sub` shape — `organization:{ORG}:module:{MODULE}:operation:test_run` — and always reports `terraform_run_phase: plan`.
## Recommendations
* **Separate the phases.** Pin `run_phase:apply` on the binding that carries write scopes, and register a second, read-only binding for `run_phase:plan` if plans need KnoxCall access at all. A speculative plan can be triggered by a pull request from someone who cannot merge it; without the phase in the rule, that plan mints the same token your apply does.
* **One binding per workspace**, not one per organisation. `sub` names the workspace, so a per-workspace rule costs nothing and makes the exchange history per-workspace too.
* Keep `access_token_ttl_seconds` near the length of a real apply. KnoxCall already caps the minted token at twice the remaining life of the Terraform token, so a long TTL mostly does nothing.
## Troubleshooting
* **`invalid_grant: no binding matched the subject_token's claims`** — the `sub` in the rule is not the `sub` in the token. Decode the token in a run to compare:
```bash theme={"dark"}
echo "$TFC_WORKLOAD_IDENTITY_TOKEN" | cut -d. -f2 \
| base64 -d 2>/dev/null | jq .
```
The usual causes are a renamed workspace, a project the workspace was moved into, and a rule written for `apply` while the failure is on `plan`.
* **`invalid_request: Unsupported audience`** — `TFC_WORKLOAD_IDENTITY_AUDIENCE` is not `knoxcall:api`. The audience is baked into the token at issue time, so it must match before the run starts.
* **`invalid_grant: subject_token has already been exchanged`** — each token is single-use. A retried apply needs the token from its own run; re-using a captured one is exactly what that refusal exists to stop.
* **`403 {"requires_step_up": true}` when creating the binding** — see the note in step 2.
# Kubernetes (self-managed, EKS, GKE, AKS)
Source: https://docs.knoxcall.com/api-reference/workload-identity/kubernetes
Authenticate from a Kubernetes pod to KnoxCall using its projected service-account token — including clusters whose issuer is unreachable from the internet
# Kubernetes Workload Identity
Every pod can be given a **projected service-account token**: a short-lived OIDC
token, signed by the cluster, that names the service account it was issued to. KnoxCall
accepts it via RFC 8693 token exchange, so a pod authenticates with no stored
KnoxCall credential at all.
The part that makes Kubernetes different from GitHub Actions or GCP is **where your
cluster publishes its signing keys**.
## Which setup do you have?
Most self-managed clusters land in the second row. If you are not sure, run
`kubectl get --raw /.well-known/openid-configuration` and look at the `jwks_uri` it
returns — then ask whether that URL is reachable from the public internet.
| Your cluster | JWKS source to choose |
| - | - |
| A managed cluster with a **public** OIDC issuer (EKS with an IAM OIDC provider, GKE Workload Identity, AKS with a public issuer) | **Discovery** — the default. Nothing extra to configure. |
| A **self-managed** cluster, or any cluster whose issuer is only reachable inside the network (`https://kubernetes.default.svc`) | **Inline** — paste the cluster's public keys. KnoxCall never makes a network call for them. |
| An issuer whose keys are published at a public URL, but whose discovery document is missing or wrong | **Explicit URL** |
`https://kubernetes.default.svc` is the default issuer string for *every* cluster, so it
is not unique to you. KnoxCall stores your keys against your tenant and your issuer
together, and an assertion is only ever verified against **your** cluster's keys — a
different customer registering the same issuer string cannot mint into your tenant, and
you cannot mint into theirs.
## 1. Get your cluster's issuer and keys
```bash theme={"dark"}
# The issuer string your tokens will carry in `iss`
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
# The public keys, as a JWKS document
kubectl get --raw /openid/v1/jwks
```
The second command prints something of the form `{"keys":[{...}]}`. That whole document
is what you paste for **Inline**. It contains **public** keys only — there is no private
key material in it.
## 2. Configure the issuer's JWKS source
For discovery clusters, skip this step.
Changing an issuer's JWKS source requires a **recent step-up verification** with a second
factor (passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes with `"action_scope": "oauth.client"`. Pasting keys is telling
KnoxCall which signer to believe for your tenant, so it carries the same bar as creating a
binding. A request carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same
session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}` (or a WebAuthn step-up with the
same scope: `POST /auth/passkey/verify-step-up`, or `POST /auth/2fa/webauthn/verify-step-up`
for a security key), then retry. A verification made for another action does not satisfy
this gate.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/federation/issuers/jwks-source \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"issuer": "https://kubernetes.default.svc",
"mode": "inline",
"jwks": { "keys": [ { "kty": "RSA", "kid": "…", "n": "…", "e": "AQAB" } ] }
}'
```
The request is **rejected** if the document cannot produce a usable key — KnoxCall parses
it before saving rather than letting you discover the problem on your first exchange.
For `inline` this check makes no network call at all.
**Inline keys do not refresh.** When your cluster rotates its service-account signing
keys, exchanges stop verifying until you paste the new document. KnoxCall names that
cause specifically rather than reporting a generic signature failure, and the binding's
status in the Dashboard shows it — but nothing rotates them for you. If your cluster
rotates on a schedule, put this on the same schedule.
## 3. Create the workload binding
Bind the service accounts you want to trust. A Kubernetes `sub` looks like:
```
system:serviceaccount::
```
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"issuer": "https://kubernetes.default.svc",
"name": "prod deployer",
"attribute_conditions": { "sub": "system:serviceaccount:prod:deployer" },
"allowed_scopes": ["secrets:read"]
}'
```
You can cover a whole namespace with a trailing `*`:
```json theme={"dark"}
{ "sub": "system:serviceaccount:prod:*" }
```
On an **inline** issuer a broad prefix is accepted, because the issuer is already the
boundary — only your cluster's signing key produces a token that verifies at all, so
`system:serviceaccount:*` grants nothing your cluster could not grant itself. On a shared
public issuer the same prefix is refused, because there it would match other people's
workloads. A bare `"*"` is always refused: it names no workload.
## 4. Project the token into your pod
```yaml theme={"dark"}
apiVersion: v1
kind: Pod
spec:
serviceAccountName: deployer
containers:
- name: app
image: your/image
env:
- name: KNOXCALL_TENANT
value: acme
volumeMounts:
- name: knoxcall-token
mountPath: /var/run/secrets/knoxcall
readOnly: true
volumes:
- name: knoxcall-token
projected:
sources:
- serviceAccountToken:
path: token
# The audience MUST match the binding's audience.
audience: knoxcall:api
# Short is good: the minted token can never outlive the assertion.
expirationSeconds: 600
```
Set `audience:` explicitly. A projected token with the cluster's default audience will not
match your binding, and the exchange refuses it — correctly, but the message reads like a
credential problem rather than a manifest one.
## 5. Exchange it
The token file is re-read **before every exchange**, never once at startup: KnoxCall
assertions are single-use, so replaying the same bytes is refused. The SDKs'
`WorkloadCredentialProvider` does this for you, including refreshing before expiry.
```javascript theme={"dark"}
import { WorkloadCredentialProvider } from '@knoxcall/sdk';
import { readFile } from 'node:fs/promises';
const provider = new WorkloadCredentialProvider({
tenant: process.env.KNOXCALL_TENANT,
// Re-read on EVERY call — kubelet rewrites this file as it rotates the token.
assertion: () => readFile('/var/run/secrets/knoxcall/token', 'utf8'),
});
const token = await provider.getAccessToken();
```
Doing it by hand is one request:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d audience=knoxcall:api \
--data-urlencode subject_token@/var/run/secrets/knoxcall/token
```
## Troubleshooting
| What you see | What it means |
| - | - |
| `subject_token has no kid` | The JWS header names no key. Every mainstream cluster sets it; if yours does not, it is a signing configuration option. |
| `no binding matched the subject_token's claims` | The `sub` does not match any binding — check the namespace and service-account name, and that `audience:` in the manifest matches the binding. |
| `the issuer publishes no signing keys KnoxCall can use` | For inline, the pasted document had no importable key. Re-run `kubectl get --raw /openid/v1/jwks`. |
| Exchanges that used to work now fail to verify | On an **inline** issuer, your cluster has rotated its keys. Paste the current JWKS. |
| `subject_token has already been exchanged` | The same token bytes were sent twice. Re-read the file before each exchange rather than caching its contents. |
# Vercel
Source: https://docs.knoxcall.com/api-reference/workload-identity/vercel
Authenticate from Vercel functions to KnoxCall using Vercel OIDC tokens
# Vercel Workload Identity
Vercel issues per-deployment OIDC tokens (`VERCEL_OIDC_TOKEN`) to every function. KnoxCall accepts them via RFC 8693 token exchange.
## 1. Enable OIDC for your Vercel project
Vercel project → **Settings → OIDC Federation → Enable**. The `VERCEL_OIDC_TOKEN` env var is then injected into every deployment.
## 2. Configure the trust binding
Create the binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API on the admin host (`admin.knoxcall.com`). The `/admin/*` routes are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`.
Creating a binding requires a **recent step-up verification** with a second factor
(passkey, security key or authenticator-app code; an emailed code does not count),
made within the last 5 minutes **for this action**: it must carry
`"action_scope": "oauth.client"`. A binding is a trust that lets an external workload
mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl`
carrying only a session JWT answers `403` with `"requires_step_up": true`,
`"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the
same session, first call `POST /auth/2fa/verify-step-up` with
`{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn
step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up`
for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then
`POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request
inside the 5-minute window. A verification made for a different action, such as one a
Dashboard prompt raised, carries that action's scope and does not satisfy this gate.
Each verification is single-use, so a retried request needs a fresh one. Listing and
revoking bindings need no verification: containment must never be gated.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \
-H "Authorization: Bearer $SESSION_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"oauth_client_id": "",
"name": "vercel-acme-prod",
"issuer": "https://oidc.vercel.com",
"audience": "knoxcall:api",
"attribute_conditions": {
"owner": "acme",
"project": "api",
"environment": "production"
},
"allowed_scopes": ["routes:read", "secrets:read"],
"access_token_ttl_seconds": 3600
}'
```
Lock to your Vercel org (`owner`), project, and environment. The token also carries `aud`, `iss`, `iat`, `exp`, plus deployment metadata (`deployment_id`, `git_commit_sha`).
## 3. Call KnoxCall from your function
```javascript theme={"dark"}
// api/list-routes.js (Vercel Edge or Node function)
import { KnoxCall } from "@knoxcall/sdk";
export default async function handler(req, res) {
const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT });
const routes = await client.routes.list();
res.json(routes);
}
```
## Recommendations
* Use separate bindings for `preview` vs `production` environments — preview deployments shouldn't be able to mint production tokens.
* Limit `allowed_scopes` to what the function actually needs.
## Troubleshooting
* **`VERCEL_OIDC_TOKEN` not set** — OIDC isn't enabled on the project, or you're running locally (`vercel dev`). For local dev, set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` directly.
* **`invalid_grant: no binding matched`** — preview deployments carry `environment: "preview"`; ensure your bindings differentiate.
# Audit Logs overview
Source: https://docs.knoxcall.com/essentials/audit-logs/overview
Tamper-evident hash-chained audit trail of every API call, secret access, config change, and admin action. Searchable, retainable, exportable.
# Audit Logs overview
KnoxCall logs every meaningful event in your tenant — secret reads, route changes, key rotations, vault tokenize / detokenize calls, webhook deliveries, admin sign-ins, policy edits — into a single **audit\_logs** table per tenant.
The log is **hash-chained**: each row stores a SHA-256 hash of its own contents plus the hash of the previous row. Tampering with any row breaks the chain at that point and every row after it. Auditors can replay the chain offline and detect a single byte-level change.
This is what compliance frameworks like SOC 2 Type II and HIPAA call a *non-repudiable audit trail* — a log that's expensive to alter without being noticed.
## Why use it
| Problem | Without KnoxCall | With KnoxCall |
| - | - | - |
| "Who decrypted this PII record?" | Per-app logs, partial coverage, easy to lose | Centralised; every detokenize tagged with user / API key |
| "Did anyone modify production after the freeze?" | Hard to prove | Hash-chain verifies untampered; configuration changes appear with old/new values |
| Compliance audit asks for 12 months of access logs | Multi-app collection + format normalisation | Export the table; retention configurable per plan |
| A leak investigation needs a precise timeline | Reconstruct from app logs | Single ordered timeline per tenant, sub-second timestamps |
## What gets logged
Every event in the audit log carries:
* `tenant_id` — your tenant
* `user_id` — the person who acted: a dashboard user, or the person signed in behind a user-bound token (the `knoxcall` CLI, device and authorization-code apps). Null for actions by an API key or a machine OAuth client
* `action` — verb-noun string (e.g. `secret.read`, `route.update`, `vault.detokenize`, `crypto.rotate`)
* `resource_type` + `resource_id` — what was acted on
* `details` — JSON payload with action-specific context (old/new values for updates, key version for crypto, etc.)
* `ip_address` — source IP from the request
* `created_at` — sub-second-precision timestamp
* `row_hash` — SHA-256 of this row's contents
* `prev_hash` — `row_hash` of the previous row in this tenant's chain
An action taken through the Management API (`/v1`) also records which credential made the request, in
`details.actor_credential` — `{ "kind": "api_key" | "oauth_client", "id": "" }`. When `user_id`
names the person behind a user-bound token, `details.actor_user_source` is `"v1_bearer"`, which tells a
CLI or app action apart from the same person at the dashboard. KnoxCall sets both fields from the
authenticated request; a value in a request body cannot change them.
Categories of event that land here:
* **Secrets** — create, read, update, delete, rotate. Every read is logged with the route or caller that triggered it.
* **Routes** — create, update, delete, environment changes, mTLS cert binding.
* **Crypto Keys** — create, rotate, encrypt, decrypt, sign, verify, JWT issue / verify, version destroy.
* **Vaults** — create, tokenize, detokenize, rotate, delete (cryptographic erasure).
* **Webhooks** — outbound delivery, inbound verification (verified or rejected with reason).
* **Admin** — sign-in, sign-out, MFA changes, role assignments, policy edits, API key creation / revocation.
* **Tenant config** — billing changes, integration adds / removes, BYOK rotation.
The proxy's per-request audit goes into a separate table — `api_requests` — because the volume is much higher and the schema differs (status code, latency, response size). Audit logs cover *changes* and *sensitive reads*; api\_requests covers *every* request.
## Hash-chain integrity
Every insert into `audit_logs` runs a BEFORE INSERT trigger that:
1. Reads the most recent `row_hash` for this tenant — that's the previous chain link.
2. Builds a deterministic content string from the new row's columns + the previous hash.
3. Sets the new row's `row_hash` to the SHA-256 of that content.
4. Sets the new row's `prev_hash` to the previous `row_hash`.
Verifying the chain after the fact:
```sql theme={"dark"}
-- For each row: recompute the content hash and check it matches row_hash.
-- Then check that this row's prev_hash equals the prior row's row_hash.
SELECT id, created_at, action,
(encode(sha256((COALESCE(prev_hash,'')
|| COALESCE(tenant_id::text,'')
|| COALESCE(user_id::text,'')
|| COALESCE(action,'')
|| COALESCE(resource_type,'')
|| COALESCE(resource_id::text,'')
|| COALESCE(details::text,'')
|| COALESCE(ip_address,''))::bytea), 'hex') = row_hash) AS row_hash_ok
FROM audit_logs
WHERE tenant_id = $1
ORDER BY id;
```
A single altered byte in any column produces a different SHA-256, which breaks the row's own hash AND every subsequent row's `prev_hash` link. The "first" altered row is provable.
The trigger lives at the database level so application code can't accidentally bypass it. Inserts go through one path; the chain is mandatory.
## Retention
| Plan | Retention |
| - | - |
| Free | 7 days |
| Starter | 7 days |
| Pro | 30 days |
| Enterprise | 90 days (extensible per contract) |
Older rows are pruned by a daily cron. The hash chain stays intact across pruning — pruning removes the oldest rows, never the middle, so verification of remaining rows continues to work.
If you need longer retention for compliance, an Enterprise SOW can extend it to 12 months or 7 years (HIPAA / SOC 2 typical asks). The data lives in your tenant's row partition and you can export it to long-term storage at any time.
## Critical audit chain
For BYOK KMS operations, secret migration events, and tenant impersonation, KnoxCall also writes to a **separate append-only table** (`audit_log_critical`) with stronger guarantees than the standard `audit_logs` table:
* **Application-layer SHA-256 chain** — KnoxCall code (not a Postgres trigger) computes each row's hash over a length-prefixed canonical byte sequence. An external verifier can reproduce every hash from raw DB values without any KnoxCall involvement.
* **No pruning** — the critical chain is never deleted, regardless of plan retention.
* **Serialized writes** — an advisory lock prevents concurrent inserts from forking the sequence.
This table is used for the 14 actions across BYOK KMS, migration lifecycle, migration item review, migration DB proxy routes, and tenant impersonation. See the [Audit Logs API reference](/api-reference/audit-logs#sha-256-hash-chain--critical-audit-chain) for the full action list, row schema, and verification endpoint.
## Filtering and search
The Audit Logs page in the admin UI supports:
* **Time window** — last hour / 24h / 7 days / custom.
* **Action prefix** — `secret.*`, `vault.*`, `crypto.*`, etc. for fast triage.
* **Resource** — type + name search.
* **User / API key** — who triggered the action.
* **Free-text search** over the JSON details payload.
Each row links to a detail page that shows the full JSON `details`, the source IP geolocation (when available), and — for resource events — the resource as it exists today plus a link to its own audit history.
## Export to SIEM
Two options:
1. **Webhook delivery** — every audit row fires an outbound webhook to your endpoint of choice. Signed with HMAC-SHA256 in any of the supported [webhook formats](/essentials/webhook-signing/overview), so your SIEM can verify authenticity end-to-end.
2. **Bulk export** — use the **Export** button in the admin UI (Logs → Audit) to download an ndjson file of the current filter set, suitable for ingestion into Splunk, Datadog, ELK, or similar.
Both methods preserve the `row_hash` / `prev_hash` columns so external systems can re-verify the chain after import.
## Quick start (UI)
1. Go to **Logs → Audit** in the admin UI.
2. Filter by action (e.g. `secret.read`) and resource type.
3. Click any row for the full detail view.
4. Use the **Export** button (top right) for an ndjson dump of the current filter set.
## Quick start (API)
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# Audit logs for a specific action (exact match)
curl -H "Authorization: Bearer $TOKEN" \
"https://api.knoxcall.com/v1/audit-logs?action=secret.read&per_page=50"
# Filter by resource type
curl -H "Authorization: Bearer $TOKEN" \
"https://api.knoxcall.com/v1/audit-logs?resource_type=secret"
```
Supported filter parameters: `action` (exact action string match), `resource_type`, `page`, `per_page`.
## Next steps
* [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — every encrypt / decrypt is audited
* [Inbound Webhooks →](/essentials/inbound-webhooks/overview) — verification results land in the audit log
* [Vaults →](/essentials/vaults/overview) — tokenize / detokenize calls audited per-token
# CIDR Notation Explained
Source: https://docs.knoxcall.com/essentials/clients/cidr-notation
Understand CIDR notation for IP ranges. Learn how to calculate subnets, authorize networks, and use CIDR in KnoxCall.
# CIDR Notation Explained
Learn how to use CIDR (Classless Inter-Domain Routing) notation to authorize ranges of IP addresses in KnoxCall.
***
## What is CIDR?
**CIDR** (pronounced "cider") is a way to represent a **range of IP addresses** using a single notation.
### Format
```text theme={"dark"}
IP_ADDRESS/PREFIX_LENGTH
```
**Example:**
```text theme={"dark"}
192.168.1.0/24
```
**Parts:**
* `192.168.1.0` - Network address (base IP)
* `/24` - Prefix length (how many IPs in range)
***
## Why Use CIDR?
### The Problem Without CIDR
Imagine authorizing an office with 200 employees:
```text theme={"dark"}
❌ Without CIDR (tedious):
192.168.1.1
192.168.1.2
192.168.1.3
...
192.168.1.200
(Need 200 separate client entries!)
```
### The Solution With CIDR
```text theme={"dark"}
✅ With CIDR (one entry):
192.168.1.0/24
(Covers 192.168.1.1 to 192.168.1.254 - all 254 usable IPs!)
```
***
## How CIDR Works
### The Prefix Number
The number after the `/` tells you how many IPs are in the range.
**Formula:**
```text theme={"dark"}
Number of IPs = 2^(32 - prefix)
```
**Examples:**
```text theme={"dark"}
/32 → 2^(32-32) = 2^0 = 1 IP
/24 → 2^(32-24) = 2^8 = 256 IPs
/16 → 2^(32-16) = 2^16 = 65,536 IPs
/8 → 2^(32-8) = 2^24 = 16,777,216 IPs
```
### Common CIDR Ranges
| CIDR | IPs | Common Use | Example |
| - | - | - | - |
| `/32` | 1 | Single host | `52.123.45.67/32` |
| `/30` | 4 | Point-to-point | `10.0.0.0/30` |
| `/28` | 16 | Small subnet | `192.168.1.0/28` |
| `/24` | 256 | **Office network** | `192.168.1.0/24` |
| `/20` | 4,096 | Large network | `172.31.0.0/20` |
| `/16` | 65,536 | **Corporate VPN** | `10.0.0.0/16` |
| `/8` | 16M+ | Very large | `10.0.0.0/8` |
***
## /24 - Most Common (256 IPs)
### Example: Office Network
```text theme={"dark"}
CIDR: 192.168.1.0/24
```
**Range covered:**
```text theme={"dark"}
First IP: 192.168.1.0 (network address - reserved)
Usable: 192.168.1.1 to 192.168.1.254
Last IP: 192.168.1.255 (broadcast - reserved)
Total: 256 addresses
Usable: 254 addresses (for devices)
```
**What changes:**
* Last octet (number): 0 to 255
* Everything else stays the same
**Authorizes:**
```text theme={"dark"}
✅ 192.168.1.1 (office computer)
✅ 192.168.1.50 (another computer)
✅ 192.168.1.100 (developer laptop)
✅ 192.168.1.254 (last usable)
❌ 192.168.2.1 (different subnet!)
❌ 192.168.0.255 (different subnet!)
```
***
## /16 - Large Networks (65,536 IPs)
### Example: Corporate VPN
```text theme={"dark"}
CIDR: 10.8.0.0/16
```
**Range covered:**
```text theme={"dark"}
First IP: 10.8.0.0
Usable: 10.8.0.1 to 10.8.255.254
Last IP: 10.8.255.255
Total: 65,536 addresses
```
**What changes:**
* Last TWO octets: 0.0 to 255.255
* First two stay the same: 10.8
**Authorizes:**
```text theme={"dark"}
✅ 10.8.0.1 (VPN user 1)
✅ 10.8.0.50 (VPN user 50)
✅ 10.8.100.200 (VPN user on different subnet)
✅ 10.8.255.254 (last usable)
❌ 10.7.0.1 (different network)
❌ 10.9.0.1 (different network)
```
***
## /20 - Cloud Subnets (4,096 IPs)
### Example: AWS VPC Subnet
```text theme={"dark"}
CIDR: 172.31.0.0/20
```
**Range covered:**
```text theme={"dark"}
First IP: 172.31.0.0
Usable: 172.31.0.1 to 172.31.15.254
Last IP: 172.31.15.255
Total: 4,096 addresses
```
**What changes:**
* Last octet: Full range (0-255)
* Third octet: Partial range (0-15)
**Authorizes:**
```text theme={"dark"}
✅ 172.31.0.1 (EC2 instance)
✅ 172.31.5.100 (another instance)
✅ 172.31.15.254 (last usable)
❌ 172.31.16.1 (outside range - /20 only goes to .15)
❌ 172.30.0.1 (different network)
```
***
## /32 - Single IP (Don't Use CIDR)
### Example: Single Server
```text theme={"dark"}
CIDR: 52.123.45.67/32
```
**Range covered:**
```text theme={"dark"}
Just: 52.123.45.67
Total: 1 address (only this specific IP)
```
**Note:** In KnoxCall, just enter `52.123.45.67` without `/32`. It's implied.
***
## Calculating CIDR Ranges
### Quick Method
**Step 1: Look at the prefix**
```text theme={"dark"}
/24 → Last octet varies (256 IPs)
/16 → Last 2 octets vary (65,536 IPs)
/8 → Last 3 octets vary (16M IPs)
```
**Step 2: Calculate range**
```text theme={"dark"}
/24 example: 192.168.1.0/24
- Base: 192.168.1.0
- Range: 192.168.1.0 to 192.168.1.255
- Usable: 192.168.1.1 to 192.168.1.254
```
### CIDR Calculator Tools
**Online calculators:**
* [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr)
* [https://cidr.xyz/](https://cidr.xyz/)
* [https://www.subnet-calculator.com/](https://www.subnet-calculator.com/)
**Example:**
```text theme={"dark"}
Input: 192.168.1.0/24
Output: First: 192.168.1.0
Last: 192.168.1.255
Usable: 192.168.1.1 - 192.168.1.254
```
***
## Common Scenarios
### Scenario 1: Office Wi-Fi
**Need:** Authorize all 50 office computers
**Solution:**
```text theme={"dark"}
CIDR: 192.168.1.0/24
```
**Why /24:**
* Office has 50 devices now
* Room to grow to 254 devices
* Typical office network size
**Client in KnoxCall:**
```text theme={"dark"}
Name: office-wifi-main
Type: Server
IP: 192.168.1.0/24
Description: Main office Wi-Fi network
```
### Scenario 2: Corporate VPN
**Need:** Authorize 500 remote employees
**Solution:**
```text theme={"dark"}
CIDR: 10.8.0.0/16
```
**Why /16:**
* VPN has 500 users now
* Room for 65,000+ connections
* Standard VPN range
**Client in KnoxCall:**
```text theme={"dark"}
Name: company-vpn
Type: Server
IP: 10.8.0.0/16
Description: Corporate VPN - all remote workers
```
### Scenario 3: AWS Subnet
**Need:** Authorize EC2 instances in subnet
**Solution:**
```text theme={"dark"}
CIDR: 172.31.0.0/20
```
**Why /20:**
* AWS VPC subnet
* \~4,000 IPs
* Multiple microservices
**Client in KnoxCall:**
```text theme={"dark"}
Name: aws-prod-subnet
Type: Server
IP: 172.31.0.0/20
Description: AWS VPC subnet - production instances
```
### Scenario 4: Partner Network
**Need:** Authorize partner company's entire network
**Solution:**
```text theme={"dark"}
CIDR: 203.14.0.0/24
```
**Why /24:**
* Partner has small office
* \~100 employees
* /24 covers them
**Client in KnoxCall:**
```text theme={"dark"}
Name: partner-acme-network
Type: Server
IP: 203.14.0.0/24
Description: ACME Corp office network
```
***
## Testing CIDR Ranges
### How to Test
**Step 1: Create client with CIDR**
```text theme={"dark"}
IP: 192.168.1.0/24
```
**Step 2: Test from different IPs**
**From 192.168.1.50:**
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/test \
-H "x-knoxcall-route: test-route"
✅ Success: 200 OK (IP in range!)
```
**From 192.168.2.50:**
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/test \
-H "x-knoxcall-route: test-route"
❌ Error: 403 Forbidden (IP not in range)
```
***
## Private IP Ranges
### Reserved Private Networks
These IP ranges are for **internal networks only** (not internet-routable):
**Class A (Large):**
```text theme={"dark"}
10.0.0.0/8
- Range: 10.0.0.0 to 10.255.255.255
- IPs: 16,777,216
- Use: Large corporate networks, VPNs
```
**Class B (Medium):**
```text theme={"dark"}
172.16.0.0/12
- Range: 172.16.0.0 to 172.31.255.255
- IPs: 1,048,576
- Use: Medium networks, cloud providers (AWS uses 172.31.0.0/16)
```
**Class C (Small):**
```text theme={"dark"}
192.168.0.0/16
- Range: 192.168.0.0 to 192.168.255.255
- IPs: 65,536
- Use: Home/office networks (most common: 192.168.1.0/24)
```
**Note:** Private IPs only work for:
* Office networks (devices behind router)
* VPNs (tunnel private network over internet)
* Cloud private subnets
**Don't use for:**
* Public internet servers (use public IPs)
***
## Public vs Private IPs
### Public IPs (Internet)
```text theme={"dark"}
Examples:
52.123.45.67 (AWS Elastic IP)
104.21.45.67 (Cloudflare)
142.250.185.78 (Google)
Format: Any IP not in private ranges
CIDR: Typically /32 (single IP) or /24 (small block)
```
**Use in KnoxCall:**
* Production servers
* Partner webhooks
* Public APIs
### Private IPs (Internal Networks)
```text theme={"dark"}
Examples:
192.168.1.0/24 (Office Wi-Fi)
10.8.0.0/16 (Corporate VPN)
172.31.0.0/20 (AWS VPC private subnet)
Format: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
CIDR: Often /16 or /24
```
**Use in KnoxCall:**
* Office networks
* VPN access
* Internal testing
***
## Common Mistakes
### ❌ Mistake 1: Using /32 for Networks
```text theme={"dark"}
❌ Wrong:
IP: 192.168.1.1/32
Type: Server
Problem: /32 = only 1 IP (defeats purpose)
```
**Fix:**
```text theme={"dark"}
✅ Correct:
IP: 192.168.1.0/24
Type: Server
```
### ❌ Mistake 2: Too Broad Range
```text theme={"dark"}
❌ Dangerous:
IP: 0.0.0.0/0
Problem: Allows ENTIRE internet!
```
**Fix:**
```text theme={"dark"}
✅ Correct:
IP: 192.168.1.0/24 (specific network only)
```
### ❌ Mistake 3: Wrong Base IP
```text theme={"dark"}
❌ Wrong:
IP: 192.168.1.55/24
Problem: Base should be .0 not .55
```
**Fix:**
```text theme={"dark"}
✅ Correct:
IP: 192.168.1.0/24
```
### ❌ Mistake 4: Prefix Doesn't Match Network
```text theme={"dark"}
❌ Wrong:
Need: 50 IPs
Used: 10.0.0.0/8 (16 million IPs!)
Problem: Massively oversized
```
**Fix:**
```text theme={"dark"}
✅ Correct:
Need: 50 IPs
Used: 10.0.0.0/24 (256 IPs - enough headroom)
```
***
## CIDR Cheat Sheet
| Prefix | IPs | Typical Use |
| - | - | - |
| `/32` | 1 | Single server (don't use CIDR) |
| `/30` | 4 | Point-to-point link |
| `/29` | 8 | Very small subnet |
| `/28` | 16 | Small office |
| `/27` | 32 | Small network |
| `/26` | 64 | Medium office |
| `/25` | 128 | Large office |
| `/24` | 256 | **Standard office network** |
| `/23` | 512 | Large office/campus |
| `/22` | 1,024 | Campus network |
| `/21` | 2,048 | Large network |
| `/20` | 4,096 | **Cloud subnet (AWS)** |
| `/19` | 8,192 | Large cloud network |
| `/18` | 16,384 | Very large network |
| `/17` | 32,768 | Enterprise |
| `/16` | 65,536 | **Corporate VPN, large enterprise** |
| `/15` | 131,072 | ISP |
| `/14` | 262,144 | Large ISP |
| `/13` | 524,288 | Very large ISP |
| `/12` | 1M+ | Regional network |
| `/11` | 2M+ | National network |
| `/10` | 4M+ | Continental network |
| `/9` | 8M+ | Very large network |
| `/8` | 16M+ | **Class A network** |
***
## Tools & Resources
### CIDR Calculators
**IP Address Guide:**
[https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr)
**CIDR.xyz:**
[https://cidr.xyz/](https://cidr.xyz/)
**Subnet Calculator:**
[https://www.subnet-calculator.com/](https://www.subnet-calculator.com/)
### Command Line Tools
**Test if IP is in CIDR range:**
```bash theme={"dark"}
# Using Python
python3 -c "
import ipaddress
network = ipaddress.ip_network('192.168.1.0/24')
ip = ipaddress.ip_address('192.168.1.50')
print(ip in network) # True or False
"
```
**List all IPs in range:**
```bash theme={"dark"}
# Using Python
python3 -c "
import ipaddress
for ip in ipaddress.ip_network('192.168.1.0/24'):
print(ip)
"
```
***
## Best Practices
### ✅ Do
1. **Use smallest range needed**
```
✅ Office has 50 users → /24 (256 IPs)
❌ Office has 50 users → /16 (65,536 IPs)
```
2. **Use .0 as base**
```
✅ 192.168.1.0/24
❌ 192.168.1.1/24
```
3. **Document what's in range**
```
Description: "Main office Wi-Fi - floors 1-3, ~100 employees"
```
4. **Test before production**
```
Test with curl from expected IP range
Verify rejection from outside range
```
5. **Use private IPs for internal**
```
✅ 192.168.1.0/24 for office
✅ 10.8.0.0/16 for VPN
```
### ❌ Don't
1. **Don't use 0.0.0.0/0**
```
❌ Allows entire internet (defeats purpose!)
```
2. **Don't over-authorize**
```
❌ Need 10 IPs → /8 (16 million!)
✅ Need 10 IPs → /28 (16 IPs)
```
3. **Don't forget private vs public**
```
❌ Using 192.168.1.0/24 for internet servers
✅ Using public IP for internet servers
```
4. **Don't mix ranges**
```
❌ One client for unrelated networks
✅ Separate clients for separate networks
```
***
## Quick Reference
| Your Network | Recommended CIDR | IPs |
| - | - | - |
| **Single server** | `52.123.45.67` (no /32) | 1 |
| **Small office** (\< 20) | `192.168.1.0/28` | 16 |
| **Medium office** (\< 100) | `192.168.1.0/24` | 256 |
| **Large office** (\< 500) | `192.168.0.0/23` | 512 |
| **VPN** (\< 1000) | `10.8.0.0/22` | 1,024 |
| **Large VPN** (\< 10k) | `10.0.0.0/18` | 16,384 |
| **Enterprise VPN** | `10.0.0.0/16` | 65,536 |
| **AWS Subnet** | `172.31.0.0/20` | 4,096 |
***
## Next Steps
Client basics and IP whitelisting
Server, User, and Network types
Create and assign clients
Troubleshoot IP problems
***
**Quick Tip:** For most offices, `/24` (256 IPs) is perfect. For VPNs, `/16` (65,536 IPs) gives plenty of headroom. When in doubt, use a CIDR calculator!
# Client Types Explained
Source: https://docs.knoxcall.com/essentials/clients/client-types
Deep dive into Server and User client types. Learn when to use each type and best practices.
# Client Types Explained
KnoxCall supports two types of clients, each optimized for different use cases. Both types support single IPs and CIDR ranges. This guide helps you choose the right type.
***
## The Two Types
| Type | Best For | IP Stability | Example |
| - | - | - | - |
| **Server** | Production servers, office networks, cloud subnets | Static (unchanging) | AWS EC2 with Elastic IP, office `192.168.1.0/24` |
| **User** | Developer machines, QA testers | Dynamic (may change) | Laptop on home Wi-Fi |
***
## Server Type
### Overview
**Server** clients represent production infrastructure with **static IP addresses** that never (or rarely) change.
### Characteristics
✅ **Static IP address** - IP doesn't change
✅ **Production-ready** - Designed for reliability
✅ **Single IP** - One specific address
✅ **High trust** - Critical infrastructure
### When to Use
Use **Server** type when:
* Production API servers
* Staging servers
* Database servers
* Backend services with static IPs
* Cloud instances with Elastic IPs
* VPS with dedicated IPs
* Partner webhook servers
### Examples
#### AWS EC2 with Elastic IP
```text theme={"dark"}
Name: prod-api-server-1
Type: Server
IP: 52.123.45.67
Description: Main production API server in AWS us-east-1
```
**Why Server type:**
* Elastic IP is static (never changes unless you release it)
* Production traffic
* Critical infrastructure
#### DigitalOcean Droplet
```text theme={"dark"}
Name: staging-backend
Type: Server
IP: 159.65.123.45
Description: Staging environment backend on DigitalOcean
```
**Why Server type:**
* Droplet has static IP
* Staging environment (important but not production)
#### Partner Integration
```text theme={"dark"}
Name: partner-acme-webhook
Type: Server
IP: 100.25.30.45
Description: ACME Corp webhook callback server
```
**Why Server type:**
* External partner's server
* Fixed IP address
* Business-critical integration
### Best Practices
✅ **Do:**
* Always use for production
* Document server purpose in description
* Use meaningful names (`prod-api-1` not `server1`)
* Track in inventory (spreadsheet/wiki)
* Regular security audits
❌ **Don't:**
* Use for developer laptops (use User type)
* Use for dynamic IPs (IP will change, causing issues)
* Use for IP ranges without also locking down the server identity
***
## User Type
### Overview
**User** clients represent individual people or devices with **potentially dynamic IP addresses**.
### Characteristics
✅ **Dynamic IP** - May change occasionally
✅ **Individual person/device** - Not infrastructure
✅ **Development-focused** - Testing, debugging
✅ **Lower trust** - Temporary access
### When to Use
Use **User** type when:
* Developer laptops
* Home office machines
* QA team computers
* Temporary contractor access
* Mobile devices (rare)
* Any IP that might change
### Examples
#### Developer Laptop
```text theme={"dark"}
Name: john-dev-macbook
Type: User
IP: 203.45.67.89
Description: John's MacBook Pro for local development
```
**Why User type:**
* Home Wi-Fi IP changes occasionally
* Individual developer
* Development/testing only
#### QA Team Member
```text theme={"dark"}
Name: qa-alice-laptop
Type: User
IP: 180.123.45.67
Description: Alice (QA) - testing environment access
```
**Why User type:**
* Office IP might change
* Individual tester
* Non-production use
#### Remote Contractor
```text theme={"dark"}
Name: contractor-bob-temp
Type: User
IP: 192.5.8.100
Description: Bob (contractor) - temporary access for Q1 2025
```
**Why User type:**
* Temporary access
* Individual person
* Dynamic IP likely
### Dynamic IP Challenges
**Problem:** Home/office internet usually has dynamic IPs that change.
**When IPs change:**
* Router restarts
* ISP lease expires (every 24-72 hours typically)
* VPN connects/disconnects
* Moving locations
**Solutions:**
**Option 1: Update IP when it changes**
```bash theme={"dark"}
# Check current IP
curl https://api.ipify.org
# Update client in KnoxCall UI
# Resources → Clients → Edit → Update IP
```
**Option 2: Use API key authentication**
* Create API key with "Allow any IP"
* Use key in requests instead
* No IP whitelisting needed
**Option 3: Use VPN with static IP**
* Connect to VPN with static egress IP
* Whitelist VPN IP
* Always connect through VPN
### Best Practices
✅ **Do:**
* Include person's name in client name
* Update description with contact info
* Remove when person leaves company
* Use "Allow any IP" API keys for dev (easier)
* Assign only to dev/staging environments
❌ **Don't:**
* Use for production servers
* Give production access to User clients
* Forget to remove when contractor leaves
* Use generic names like `user1`
***
## Using CIDR Ranges
Both Server and User types support **CIDR notation** for authorizing IP ranges. This is useful for office networks, VPNs, and cloud subnets.
### Examples with CIDR
#### Office Network
```text theme={"dark"}
Name: office-wifi-main
Type: Server
IP: 192.168.1.0/24
Description: Main office Wi-Fi - all floors, ~200 employees
```
Covers 256 addresses: `192.168.1.0` to `192.168.1.255`
#### Corporate VPN
```text theme={"dark"}
Name: company-vpn
Type: Server
IP: 10.8.0.0/16
Description: Corporate VPN - all remote employees
```
Covers 65,536 addresses: `10.8.0.0` to `10.8.255.255`
#### Cloud Provider Subnet
```text theme={"dark"}
Name: aws-us-east-subnet
Type: Server
IP: 172.31.0.0/20
Description: AWS VPC subnet for microservices
```
Covers 4,096 addresses: `172.31.0.0` to `172.31.15.255`
### Common CIDR Ranges
* `/32` - 1 IP (just enter the IP without `/32`)
* `/24` - 256 IPs (e.g., `192.168.1.0/24`)
* `/16` - 65,536 IPs (e.g., `10.0.0.0/16`)
* `/8` - 16,777,216 IPs (very large, rare)
See [CIDR Notation Guide](/essentials/clients/cidr-notation) for details.
### CIDR Best Practices
✅ **Do:**
* Use smallest range that covers your needs
* Document what network the range represents
* Audit periodically (remove if unused)
❌ **Don't:**
* Use `0.0.0.0/0` (allows entire internet!)
* Use overly broad ranges unnecessarily
***
## Comparison Table
| Feature | Server | User |
| - | - | - |
| **IP Format** | Single IP or CIDR range | Single IP or CIDR range |
| **Example IP** | `52.123.45.67` or `192.168.1.0/24` | `203.45.67.89` |
| **Stability** | Permanent | May change |
| **Use Case** | Production servers, office networks | Developer laptops, QA testers |
| **Trust Level** | High | Medium |
| **Environment** | Production, Staging | Dev/Staging |
| **Maintenance** | Low | Medium (IP updates) |
***
## Choosing the Right Type
### Decision Tree
```text theme={"dark"}
Is this infrastructure (servers, networks, VPNs)?
├─ YES → Use Server type
└─ NO → Is this a person (developer, tester)?
└─ YES → Use User type
```
### Examples
**Scenario: AWS EC2 instance**
* Infrastructure? ✅ Yes (static Elastic IP)
* **→ Server type**
**Scenario: Developer's laptop**
* Infrastructure? ❌ No (person's device)
* **→ User type**
**Scenario: Office with 50 employees**
* Infrastructure? ✅ Yes (network)
* **→ Server type** with CIDR range (e.g., `192.168.1.0/24`)
**Scenario: VPN with 100 users**
* Infrastructure? ✅ Yes (VPN network)
* **→ Server type** with CIDR range (e.g., `10.8.0.0/16`)
***
## Security Considerations
### Trust Levels
**Server (Highest Trust):**
* ✅ Production traffic allowed
* ✅ Access to sensitive data
* ✅ Critical operations
* ⚠️ CIDR ranges increase exposure — use the smallest range needed
**User (Medium Trust):**
* ⚠️ Development/staging only
* ⚠️ Limited permissions
* ⚠️ Regular audits
### Recommendations by Environment
**Production:**
* ✅ Server type only
* ❌ No User types
* ⚠️ CIDR ranges only for trusted corporate networks
**Staging:**
* ✅ Server type for staging servers
* ✅ User type for QA team
**Development:**
* ✅ Both types acceptable
* ✅ User type most common
* ✅ API keys easier than IP whitelisting
***
## Migration Between Types
### User → Server (When Getting Static IP)
**Before:**
```text theme={"dark"}
Name: dev-server
Type: User
IP: 203.45.67.89 (dynamic)
```
**After getting Elastic IP:**
```text theme={"dark"}
Name: prod-api-server
Type: Server
IP: 52.123.45.67 (Elastic IP - static)
```
**Steps:**
1. Provision Elastic IP from cloud provider
2. Assign to instance
3. Edit client in KnoxCall
4. Change type to Server
5. Update IP to Elastic IP
6. Update description
7. Save
### Network → Multiple Servers
**Before (inefficient):**
```text theme={"dark"}
Name: cloud-subnet
Type: Server
IP: 172.31.0.0/20
Description: All instances (10 production, 50 dev)
```
**After (more secure):**
```text theme={"dark"}
Production servers (Server type):
- prod-api-1: 172.31.1.10
- prod-api-2: 172.31.1.11
- prod-db-1: 172.31.1.20
Development network (Server type with CIDR):
- dev-subnet: 172.31.8.0/22
```
**Benefit:** Production servers explicitly listed, dev instances in subnet.
***
## Real-World Patterns
### Pattern 1: Startup (Small Team)
```text theme={"dark"}
Production:
- Server: prod-api (52.123.45.67)
- Server: prod-db (52.123.45.68)
Development:
- User: john-laptop (dynamic)
- User: jane-laptop (dynamic)
- User: bob-laptop (dynamic)
```
**Why:** Simple setup, few people, API keys easier than IP management for devs.
### Pattern 2: Enterprise (Large Team)
```text theme={"dark"}
Production:
- Server: prod-api-1 through prod-api-10 (specific IPs)
- Server: prod-worker-1 through prod-worker-5
Staging:
- Server: staging-subnet (10.1.0.0/24)
- Server: office-network (192.168.0.0/16)
Development:
- Server: dev-vpn (10.8.0.0/16)
```
**Why:** Granular production control, convenient staging/dev access.
### Pattern 3: Agency/Consultancy
```text theme={"dark"}
Clients:
- Server: client-acme-network (203.14.0.0/24)
- Server: client-widgets-vpn (10.9.0.0/16)
- Server: client-globex-server (100.25.30.45)
Internal:
- Server: office-wifi (192.168.1.0/24)
- User: contractor-alice (temporary)
- User: contractor-bob (temporary)
```
**Why:** Each client gets appropriate access, contractors temporary User type.
***
## Quick Reference
| Situation | Type | Example |
| - | - | - |
| Production server with Elastic IP | Server | `52.123.45.67` |
| Developer's laptop at home | User | `203.45.67.89` |
| Office Wi-Fi network | Server | `192.168.1.0/24` |
| Staging server on cloud | Server | `54.200.11.22` |
| QA team member | User | `180.123.45.67` |
| Corporate VPN | Server | `10.8.0.0/16` |
| Partner's webhook server | Server | `100.25.30.45` |
| Temporary contractor | User | `192.5.8.100` |
***
## Next Steps
Client basics and concepts
Create, edit, assign clients
Understanding IP ranges (coming soon)
Troubleshoot IP problems
***
**Pro Tip:** For production, always use **Server** type with static IPs. For development, use **User** type or API keys with "Allow any IP" to avoid constant IP updates.
# Managing Clients
Source: https://docs.knoxcall.com/essentials/clients/managing-clients
Learn how to create, edit, delete, and assign clients in KnoxCall. Complete guide to client lifecycle management.
# Managing Clients
Complete guide to creating, editing, deleting, and assigning clients for IP authorization.
***
## Creating Clients
### Step 1: Navigate to Clients
1. Click **Resources** in the sidebar
2. Select **Clients**
3. Click **Create Client** button
### Step 2: Fill in Client Details
#### Name (Required)
Give your client a descriptive name.
**Good examples:**
```text theme={"dark"}
production-api-server-1
staging-server-aws
john-dev-laptop
office-network-main
partner-acme-webhook-server
```
**Bad examples:**
```text theme={"dark"}
client1 (not descriptive)
server (too generic)
test (what is this?)
```
#### Type (Required)
Choose the appropriate type:
| Type | Best For | Example |
| - | - | - |
| **Server** | Production servers, office networks, cloud subnets | AWS EC2 with Elastic IP, `192.168.1.0/24` |
| **User** | Developer machines, dynamic IPs | Laptops, home offices |
**Recommendation:** Use **Server** for production infrastructure, **User** for individual people. Both types support single IPs and CIDR ranges.
#### IP Address (Required)
Enter the IP address or CIDR range.
**Single IP:**
```text theme={"dark"}
52.123.45.67
```
**IP Range (CIDR):**
```text theme={"dark"}
192.168.1.0/24 (256 IPs: .0 to .255)
10.0.0.0/16 (65,536 IPs)
203.14.0.0/24 (256 IPs for office)
```
**How to find your IP:**
```bash theme={"dark"}
curl https://api.ipify.org
```
#### Description (Optional but Recommended)
Document what this client is for.
**Good descriptions:**
```text theme={"dark"}
"Main production API server in AWS us-east-1"
"John's development MacBook Pro"
"Office network - all floors"
"Partner XYZ webhook callback server"
"Staging environment in GCP"
```
#### Enabled Toggle
* **ON** (default): Client is active and authorized
* **OFF**: Client is disabled, requests will be rejected
### Step 3: Save
Click **Create Client**
✅ Client is now created but **not yet assigned** to any routes!
***
## Assigning Clients to Routes
Creating a client doesn't automatically authorize it. You must **assign it to specific routes**.
### Method 1: Assign from Route Page
**Best for:** Assigning to one route
1. Go to **Routes**
2. Click on your route
3. Go to **Clients** tab
4. Select environment (e.g., "production")
5. Click **Assign Clients**
6. Check the boxes next to clients you want to authorize
7. Click **Save**
✅ Those clients can now access this route in that environment!
### Method 2: Assign from Client Page
**Best for:** Assigning one client to multiple routes
1. Go to **Resources** → **Clients**
2. Click on your client
3. Go to **Assigned Routes** tab
4. Click **Assign to Routes**
5. Select routes and environments
6. Click **Save**
***
## Per-Environment Assignment
Clients can be assigned **differently per environment**.
### Example Setup
**Route:** `user-api`
**Production environment:**
* Clients: `prod-server-1`, `prod-server-2`
**Staging environment:**
* Clients: `staging-server`, `qa-laptop-1`, `qa-laptop-2`
**Development environment:**
* Clients: `dev-laptop-1`, `dev-laptop-2`, `dev-laptop-3`
### How to Assign Per Environment
1. Go to route → **Clients** tab
2. **Select environment** dropdown (e.g., "production")
3. Click **Assign Clients**
4. Check clients for **this environment only**
5. Save
6. **Repeat for each environment**
**Result:**
* Production requests only work from production servers
* Staging requests work from staging + QA machines
* Dev requests work from developer laptops
***
## Platform-Managed Clients
Two clients in your list are created by KnoxCall itself and cannot be presented by anyone else:
| Client | What it identifies | Assign it when |
| - | - | - |
| **KnoxCall Dashboard** | The dashboard's **Call Route** test button | You want to test a route from the dashboard on an environment that requires authorized clients |
| **KnoxCall Workflows** | A workflow **HTTP Request** node in **Route** mode calling one of your routes | You want workflows to call routes on an environment that requires authorized clients |
Each one proves its identity with a signed assertion that is bound to your workspace, the route, the environment and the Live/Test space — it cannot be replayed from outside KnoxCall or by another workspace. They grant nothing until you assign them, exactly like any other client, and removing the assignment revokes the access. Deleting one simply recreates it, unassigned, the next time it is needed. The assignment is honoured by both kinds of gate: an environment that **requires authorized clients**, and a route that has an **IP allowlist** (the platform's own address is never on the list, so the assignment is what lets its call through).
Do not allowlist KnoxCall's own outbound IP address as a Server client or in a route's IP allowlist. That address is shared by every workspace's traffic, so it cannot identify yours; the platform refuses it, and the platform-managed clients above are the supported way to let KnoxCall call your routes.
***
## Editing Clients
### When to Edit
Common reasons to edit:
* IP address changed (especially for User type)
* Need to update description
* Need to enable/disable temporarily
* Expanding IP range (CIDR)
### How to Edit
1. Go to **Resources** → **Clients**
2. Find your client in the list
3. Click on the client name
4. Click **Edit** button
5. Update fields as needed
6. Click **Save**
### Common Edits
#### Update IP Address
**When:** Your development machine IP changed
**Before:**
```text theme={"dark"}
IP: 203.45.67.89
```
**After:**
```text theme={"dark"}
IP: 203.45.67.100
```
**How to check current IP:**
```bash theme={"dark"}
curl https://api.ipify.org
```
#### Expand IP Range
**When:** Need to cover more IPs
**Before:**
```text theme={"dark"}
IP: 192.168.1.0/24 (covers .0 to .255)
```
**After:**
```text theme={"dark"}
IP: 192.168.0.0/16 (covers .0.0 to .255.255)
```
#### Change Type
**When:** Server gets static IP
**Before:**
```text theme={"dark"}
Type: User
IP: 203.45.67.89 (dynamic)
```
**After:**
```text theme={"dark"}
Type: Server
IP: 52.123.45.67 (Elastic IP - static)
```
***
## Enabling/Disabling Clients
### Temporarily Disable
**When to use:**
* Testing without deleting
* Temporarily block access
* Troubleshooting
**How:**
1. Edit client
2. Toggle **Enabled** to **OFF**
3. Save
**Result:** Client exists but requests are rejected.
### Re-Enable
**How:**
1. Edit client
2. Toggle **Enabled** to **ON**
3. Save
**Result:** Client is active again.
***
## Deleting Clients
### When to Delete
* Client is no longer needed
* IP is permanently decommissioned
* Cleaning up old test clients
* Security: remove unauthorized access
### ⚠️ Warning
Deleting a client is **permanent** and will:
* Remove client from ALL routes
* Reject all future requests from this IP
* Cannot be undone
**Make sure you want to delete!**
### How to Delete
1. Go to **Resources** → **Clients**
2. Find your client
3. Click on client name
4. Click **Delete** button
5. Confirm deletion
✅ Client is permanently removed.
### Safe Alternative: Disable Instead
Instead of deleting, consider **disabling**:
* Keeps configuration
* Can be re-enabled later
* Reversible
***
## Viewing Client Information
### Client Details Page
Click on any client to see:
**Basic Info:**
* Name, Type, IP address
* Description
* Enabled status
* Created date
**Assigned Routes:**
* Which routes this client can access
* Which environments it's assigned to
**Activity Log:**
* Recent requests from this IP
* Success/failure rate
* Last seen timestamp
***
## Bulk Operations
### Assign Multiple Clients at Once
**Scenario:** Add 5 developer laptops to dev environment
**Steps:**
1. Go to route → **Clients** tab
2. Select "development"
3. Click **Assign Clients**
4. Check all 5 developer clients at once
5. Save
✅ All 5 clients assigned in one operation!
### Create Multiple Clients from CSV
**Not currently supported in UI** - Need to create one by one.
**Workaround for many clients:**
Use the KnoxCall API to script client creation:
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
for ip in $(cat ip_list.txt); do
curl -X POST https://api.knoxcall.com/v1/clients \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\": \"server-$ip\", \"ip_address\": \"$ip\", \"type\": \"server\"}"
done
```
***
## Client Organization
### Naming Convention
Use consistent naming for easy management:
**Pattern:** `{environment}-{purpose}-{identifier}`
**Examples:**
```text theme={"dark"}
prod-api-server-1
prod-api-server-2
staging-app-server
dev-john-laptop
dev-jane-laptop
office-network-floor-1
partner-stripe-webhook
```
### Use Descriptions
Always add descriptions to remember purpose:
```text theme={"dark"}
Name: prod-api-server-1
Description: Main production API server in AWS us-east-1, Elastic IP 52.123.45.67
Name: dev-john-laptop
Description: John's MacBook Pro for local development, IP changes frequently
Name: office-network
Description: Main office Wi-Fi network covering all floors, 192.168.1.0/24
```
***
## Common Scenarios
### Scenario 1: New Developer Joins
**Steps:**
1. Ask developer for their IP: `curl https://api.ipify.org`
2. Create client:
* Name: `dev-alice-laptop`
* Type: User
* IP: `203.50.60.70`
3. Assign to development environment of all necessary routes
4. Developer can now test locally!
### Scenario 2: Move to Production
**Steps:**
1. Provision production server with **static IP** (Elastic IP)
2. Create client:
* Name: `prod-api-server-1`
* Type: Server
* IP: `52.100.200.50` (Elastic IP)
3. Assign to **production environment only** (not staging/dev)
4. Test carefully before going live
### Scenario 3: Office Network
**Steps:**
1. Get office network CIDR range: `192.168.1.0/24`
2. Create client:
* Name: `office-network-main`
* Type: Server
* IP: `192.168.1.0/24`
3. Assign to staging or dev environments (not production!)
4. All office computers can now access
### Scenario 4: IP Changed Unexpectedly
**Symptoms:** Requests suddenly fail with "IP not authorized"
**Steps:**
1. Check current IP: `curl https://api.ipify.org`
2. Compare to client IP in KnoxCall
3. If different → Edit client
4. Update IP to new value
5. Save → Requests work again!
**Prevention for dev:**
Use token auth instead of IP whitelisting (see below).
***
## Alternative: Token Authentication
For environments with dynamic IPs, consider using token-based auth instead:
### When to Use Tokens Instead
* Developer laptops (IP changes frequently)
* Mobile devices
* Serverless functions (Lambda, Cloud Functions)
* Behind CDN/proxy with changing IPs
### How to Set Up
The recommended path is an OAuth access token — the [SDK](/sdks/overview) mints and refreshes it for you after [`knoxcall login`](/api-reference/authentication):
```typescript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login`
const resp = await client.call("user-api", { path: "/api/users" });
```
Or raw curl with a minted token:
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/users \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api"
```
**Legacy API key:** create one in **Settings** → **Tenant** tab → **API Keys**, enable **"Allow any IP"** for dev/test keys, and send it in the `x-knoxcall-key` header:
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/users \
-H "x-knoxcall-key: tk_test_abc123..." \
-H "x-knoxcall-route: user-api"
```
No IP whitelisting needed!
**Note:** Only use "Allow any IP" for dev/test keys, never production.
***
## Security Best Practices
### ✅ Do
1. **Use descriptive names** - Know what each client is for
2. **Add descriptions** - Document IP source and purpose
3. **Use static IPs for production** - Elastic IPs, not dynamic
4. **Separate environments** - Different clients for prod/staging/dev
5. **Regular audits** - Review and remove unused clients monthly
6. **Least privilege** - Only assign to routes that need it
### ❌ Don't
1. **Don't use 0.0.0.0/0** - This allows ALL IPs (defeats purpose!)
2. **Don't share clients** - Production and dev should have separate clients
3. **Don't ignore changes** - Update IPs promptly when they change
4. **Don't use generic names** - "client1", "test" are confusing
5. **Don't allow dev IPs in prod** - Keep environments isolated
***
## Troubleshooting
### Client Created But Requests Rejected
**Cause:** Client not assigned to route.
**Solution:**
1. Go to route → **Clients** tab
2. Select environment
3. Assign your client
4. Try again
### IP Keeps Changing
**Cause:** Dynamic IP from ISP (home/office internet).
**Solution:**
* **Option A:** Update IP each time it changes
* **Option B:** Use token auth (SDK/OAuth, or a legacy API key with "Allow any IP")
* **Option C:** Use VPN with static IP
### Wrong Client Authorized
**Symptom:** Wrong server can access route.
**Solution:**
1. Review **Clients** tab for route
2. Remove unauthorized clients
3. Only keep necessary clients assigned
***
## Quick Reference
| Task | Steps |
| - | - |
| **Create client** | Resources → Clients → Create |
| **Edit client** | Clients → Click name → Edit |
| **Delete client** | Clients → Click name → Delete |
| **Assign to route** | Route → Clients tab → Assign |
| **Disable client** | Edit → Toggle Enabled OFF |
| **Check current IP** | `curl https://api.ipify.org` |
***
## Next Steps
Understand IP whitelisting basics
Server vs User types explained
Understanding IP ranges (coming soon)
Fix common IP problems
***
**Pro Tip:** For production, always use **Server** type with static IPs. For development, use **User** type or token auth to handle dynamic IPs easily.
# What are Clients?
Source: https://docs.knoxcall.com/essentials/clients/what-are-clients
Understand IP whitelisting and client authorization in KnoxCall. Learn how clients provide network-level security.
# What are Clients?
A **client** in KnoxCall is an authorized IP address or network that's allowed to make requests through your routes. Think of clients as your "guest list" for API access.
## The Bouncer Analogy
Imagine KnoxCall as a nightclub bouncer:
* **Without clients**: Anyone on the internet can try to access your routes
* **With clients**: Only IPs on your whitelist get through the door
```text theme={"dark"}
Internet User (Random IP) → 🚫 Blocked
Your Server (Whitelisted) → ✅ Allowed
```
***
## What Problem Do Clients Solve?
### The Problem: Open Access
Without client authorization, your KnoxCall routes are accessible to anyone who knows the URL:
```bash theme={"dark"}
# Anyone can call this if they know your subdomain
curl https://a1b2c3d4.acme.knoxcall.com/api/users \
-H "x-knoxcall-route: user-api"
```
**Risks:**
* Attackers can spam your routes
* Unexpected traffic costs
* Potential DDoS attacks
* Unauthorized access attempts
### The Solution: IP Whitelisting
With client authorization enabled, only specific IP addresses can access your routes:
```text theme={"dark"}
Your Production Server (52.123.45.67) → ✅ Whitelisted
Your Office Network (203.14.0.0/16) → ✅ Whitelisted
Random Internet User (100.5.8.200) → ❌ BLOCKED
Attacker (45.67.89.10) → ❌ BLOCKED
```
***
## How Client Authorization Works
### The Request Flow
```text theme={"dark"}
1. Request arrives at KnoxCall
↓
2. KnoxCall extracts source IP address
↓
3. Checks if IP matches any authorized client
↓
4. If YES → Process route
If NO → Return 403 Forbidden
```
### Example
**Your clients:**
* Production server: `52.123.45.67`
* Staging server: `54.200.11.22`
**Request from production (52.123.45.67):**
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/users \
-H "x-knoxcall-route: user-api"
# ✅ Success: 200 OK
```
**Request from unknown IP (100.5.8.200):**
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/users \
-H "x-knoxcall-route: user-api"
# ❌ Error: 403 Forbidden
# "IP address not authorized for this route"
```
***
## Client Types
KnoxCall supports two types of clients:
### 1. Server (Recommended for Production)
**Best for**: Production servers with static IPs, office networks, cloud subnets
**Characteristics:**
* Fixed IP address or CIDR range (e.g., AWS Elastic IP or VPC subnet)
* Never changes
* Most reliable authorization method
**Examples:**
```text theme={"dark"}
Name: production-api-server
Type: Server
IP: 52.123.45.67
Description: Main API server (AWS us-east-1)
```
```text theme={"dark"}
Name: office-network
Type: Server
IP: 192.168.1.0/24
Description: Company office network (all employees)
```
### 2. User (For Development)
**Best for**: Developer machines, dynamic IPs
**Characteristics:**
* IP may change occasionally
* Great for local development
* Can be updated when IP changes
**Example:**
```text theme={"dark"}
Name: john-dev-laptop
Type: User
IP: 203.45.67.89
Description: John's development laptop
```
Both Server and User types support single IPs and CIDR ranges. The type is a label to help you organize — Server for infrastructure, User for people.
***
## IP Address Formats
### Single IP Address
Most common format for individual servers:
```text theme={"dark"}
52.123.45.67
```
Authorizes exactly one IP address.
### CIDR Notation (IP Ranges)
For networks and ranges:
```text theme={"dark"}
192.168.1.0/24
```
**What does `/24` mean?**
* Authorizes 192.168.1.0 through 192.168.1.255
* That's 256 IP addresses
**Common CIDR ranges:**
* `/32` = 1 IP (e.g., 192.168.1.1/32)
* `/24` = 256 IPs (e.g., 192.168.1.0/24)
* `/16` = 65,536 IPs (e.g., 10.0.0.0/16)
**Example office network:**
```text theme={"dark"}
Name: acme-corp-office
IP: 203.14.0.0/16
Description: All office locations
```
This authorizes:
* 203.14.0.1
* 203.14.0.2
* ... (all IPs from 203.14.0.0 to 203.14.255.255)
***
## Per-Environment Client Authorization
One of KnoxCall's most powerful features: **different clients for different environments**.
### The Concept
You can assign different sets of clients to:
* Production environment
* Staging environment
* Development environment
### Real-World Example
**Route**: `user-api`
**Production environment clients:**
* `52.123.45.67` (prod server)
* `52.123.45.68` (prod server backup)
**Staging environment clients:**
* `54.200.11.22` (staging server)
* `203.45.67.89` (your dev laptop)
**Result:**
* Production traffic comes from prod IPs only
* Staging can be accessed from staging server AND your laptop
* Clear separation between environments
***
## Common Use Cases
### 1. Production API Server
**Scenario**: Your backend server needs to call third-party APIs
**Setup:**
```text theme={"dark"}
Client:
Name: prod-api-server-1
Type: Server
IP: 52.123.45.67 (AWS Elastic IP)
Route: stripe-payments
Assigned Clients: prod-api-server-1
```
**Benefit**: Only your production server can charge customers via Stripe route.
### 2. Office Network
**Scenario**: Multiple team members need to test webhooks
**Setup:**
```text theme={"dark"}
Client:
Name: acme-office-network
Type: Server
IP: 203.14.0.0/24 (covers all office computers)
Route: test-webhooks
Assigned Clients: acme-office-network
```
**Benefit**: Everyone in the office can test, but external users can't.
### 3. Partner Integration
**Scenario**: External partner needs to send webhooks to your system
**Setup:**
```text theme={"dark"}
Client:
Name: partner-acme-corp
Type: Server
IP: 100.25.30.45 (partner's webhook server)
Route: partner-webhooks
Assigned Clients: partner-acme-corp
```
**Benefit**: Partner can send webhooks, but no one else can.
### 4. Development Team
**Scenario**: 5 developers working remotely with dynamic IPs
**Option A: Individual clients (tedious)**
```text theme={"dark"}
john-laptop: 203.45.67.89
jane-laptop: 180.123.45.67
bob-laptop: 192.5.8.100
...
```
**Option B: Use API key auth (better)**
* Create test API key with `skip_ip_check: true`
* Developers use API key instead of IP whitelist
* More flexible for dynamic IPs
***
## Client Priority and Fallback
### Assignment Levels
Clients can be assigned at two levels:
1. **Route-level (global)**: All environments use these clients
2. **Environment-level**: Specific clients per environment
**Environment-specific always wins:**
```text theme={"dark"}
Route "user-api":
Global clients: [prod-server, staging-server]
Environment "production":
Clients: [prod-server only]
Environment "staging":
Clients: [staging-server, dev-laptop]
```
**Result:**
* Production: Only `prod-server` allowed
* Staging: `staging-server` and `dev-laptop` allowed
* Global clients ignored when environment has specific clients
***
## Security Best Practices
### ✅ Do
* **Use specific IPs** - Don't whitelist entire internet ranges
* **Use CIDR for networks** - Easier to manage office networks
* **Separate environments** - Different clients for prod/staging/dev
* **Use descriptive names** - `prod-api-server-1` not `server1`
* **Document why** - Add descriptions explaining each client's purpose
* **Regular audits** - Remove unused clients monthly
### ❌ Don't
* **Don't use 0.0.0.0/0** - This allows ALL IPs (defeats the purpose!)
* **Don't reuse clients** - Production and staging should have separate clients
* **Don't forget to update** - When IPs change, update immediately
* **Don't whitelist untrusted IPs** - Only authorize systems you control
* **Don't use generic names** - `client1`, `client2` are unmaintainable
***
## IP Detection
KnoxCall automatically detects the source IP from these headers (in order):
1. **cf-connecting-ip** (Cloudflare - most reliable)
2. **x-forwarded-for** (Standard proxy header - first IP only)
3. **Socket remote address** (Direct connection fallback)
This ensures accurate IP detection even behind proxies and CDNs.
***
## When NOT to Use Clients
Client authorization might not be necessary if:
1. **Public webhooks** - Stripe, Shopify send from many IPs (use signature validation instead)
2. **CDN origins** - Cloudflare, Fastly have changing IP ranges (use request signing)
3. **Mobile apps** - Users have random IPs (use API key auth)
4. **Serverless functions** - Lambda, Cloud Functions have dynamic IPs (use API keys)
**Alternative**: Use request signing (`x-knoxcall-signature`) for these scenarios.
***
## Quick Reference
| Client Type | Best For | Example IP | Format |
| - | - | - | - |
| Server | Production servers, office networks | `52.123.45.67` or `192.168.1.0/24` | Single IP or CIDR range |
| User | Developer machines | `203.45.67.89` | Single IP or CIDR range |
***
## Next Steps
Step-by-step tutorial
Create, edit, delete, and assign clients
Deep dive into Server, User, and Network types
Understanding IP ranges
***
**Key Takeaway**: Clients are your first line of defense. They provide network-level security by ensuring only authorized IPs can access your routes.
# Collection Variables
Source: https://docs.knoxcall.com/essentials/collections/collection-variables
Use variables to share configuration values and secrets across routes in a collection
# Collection Variables
## Overview
Collection variables are reusable key-value pairs that can be referenced across all routes within a collection. They provide a centralized way to manage configuration values and secret references, ensuring consistency and reducing duplication.
## Variable Types
### Value Variables
Standard variables that store plain-text configuration values like API endpoints, timeout settings, or feature flags.
**Example:**
```text theme={"dark"}
Key: api_timeout
Value: 30000
```
### Secret Variables
Variables that reference encrypted secrets stored in your KnoxCall vault. These are ideal for sensitive data like API keys, passwords, or tokens.
**Example:**
```text theme={"dark"}
Key: stripe_api_key
Secret: Stripe Production Key
```
When a route uses this variable, KnoxCall automatically resolves the secret value from the vault.
## Creating Variables
Open your collection and go to the **Variables** tab
Click the **+** button in the Variables card header
* **Key**: Enter a unique identifier (alphanumeric and underscores only)
* **Type**: Choose "Value" for plain text or "Secret" for sensitive data
* **Value/Secret**: Enter the value or select an existing secret
* **Description**: (Optional) Add notes about the variable's purpose
Click **Create** to save the variable
## Using Variables in Routes
Reference collection variables in your routes using the template syntax:
```text theme={"dark"}
{{var:variable_name}}
```
### Supported Fields
Collection variables can be used in:
* **Target URLs** - `https://api.example.com/{{var:api_version}}/users`
* **Headers** - `Authorization: Bearer {{var:auth_token}}`
* **Query Parameters** - `?api_key={{var:api_key}}`
* **Request Bodies** - JSON values that reference variables
### Example: Using Variables in a Route
**Collection Variables:**
```text theme={"dark"}
base_domain = api.stripe.com
stripe_secret = [Secret: Stripe Production Key]
api_version = v1
```
**Route Configuration:**
```text theme={"dark"}
Target URL: https://{{var:base_domain}}/{{var:api_version}}/charges
Headers:
Authorization: Bearer {{var:stripe_secret}}
Content-Type: application/json
```
When a request is proxied, KnoxCall resolves:
```text theme={"dark"}
Target URL: https://api.stripe.com/v1/charges
Headers:
Authorization: Bearer sk_live_EXAMPLE_xxxxxxxxxxxxx
Content-Type: application/json
```
## Variable Resolution
### Priority Order
When a variable name appears in multiple places, KnoxCall resolves it in this order:
1. **Environment-specific overrides** (coming soon)
2. **Collection variables** (current)
3. **Route-level variables** (future feature)
### Environment Context
When using [Environment-Specific Secrets](/essentials/secrets/environment-specific-secrets), collection variables automatically resolve to the correct environment's secret value based on the active environment of the route.
**Example:**
* Variable `api_key` references secret "API Key"
* Secret "API Key" has different values for `production` and `staging`
* When route is accessed in `production` environment → uses production API key
* When route is accessed in `staging` environment → uses staging API key
## Best Practices
Choose clear, self-documenting variable names like `stripe_webhook_secret` instead of `secret1`
Always use secret variables (not value variables) for API keys, passwords, tokens, and other sensitive data
Use the Description field to explain what each variable is for and how it should be used
Organize variables by prefix: `stripe_api_key`, `stripe_webhook_secret`, `stripe_account_id`
If multiple collections need the same variable, consider if they should be merged into one collection
## Common Use Cases
### API Versioning
```text theme={"dark"}
base_url = https://api.example.com
api_version = v2
Route: {{var:base_url}}/{{var:api_version}}/endpoint
```
### Multi-Tenant Configuration
```text theme={"dark"}
tenant_id = customer_123
tenant_api_key = [Secret: Customer 123 API Key]
Header: X-Tenant-ID: {{var:tenant_id}}
Header: Authorization: Bearer {{var:tenant_api_key}}
```
### Feature Flags
```text theme={"dark"}
enable_new_auth_flow = true
max_retry_attempts = 3
Query: ?use_new_auth={{var:enable_new_auth_flow}}
Query: ?max_retries={{var:max_retry_attempts}}
```
### Environment-Specific Endpoints
```text theme={"dark"}
database_url = [Secret: Production DB Connection String]
cache_ttl = 3600
Config: connection={{var:database_url}}&ttl={{var:cache_ttl}}
```
## Managing Variables
### Editing Variables
1. Navigate to the **Variables** tab in your collection
2. Click on the variable row to open the editor
3. Modify the value or description
4. Click **Update** to save changes
### Copying Variable References
1. In the Variables tab, click the **⋯** menu for a variable
2. Select **Copy reference**
3. The variable syntax `{{var:key_name}}` is copied to your clipboard
### Deleting Variables
Deleting a variable will break any routes that reference it. Always verify usage before deletion.
1. Click the **⋯** menu for the variable
2. Select **Delete**
3. Confirm the deletion
## Troubleshooting
**Symptoms:** Route shows `{{var:key_name}}` in logs instead of the actual value
**Solutions:**
* Verify the variable key is spelled correctly (case-sensitive)
* Check that the variable exists in the collection
* Ensure the route is assigned to the correct collection
**Symptoms:** Secret variable resolves to empty string
**Solutions:**
* Verify the secret exists and has a value
* Check that the secret is not deleted
* For environment-specific secrets, ensure the secret has a value for the active environment
**Symptoms:** Can't create variable with certain characters
**Solutions:**
* Variable keys must be alphanumeric plus underscores only
* Use `api_key` instead of `api-key` or `api.key`
* Keys cannot start with a number
## Next Steps
Learn how to use different secret values per environment
Configure default settings per environment at the collection level
# What are Collections?
Source: https://docs.knoxcall.com/essentials/collections/collections-overview
Organize and manage your routes, secrets, and environment configurations with Collections
# What are Collections?
## Overview
Collections in KnoxCall are organizational containers that help you group related routes, secrets, and variables together. They provide a structured way to manage shared configurations across multiple routes and maintain consistency in your API proxy setup.
The **Secrets** page now uses a two-level hierarchy with collections as the top level. Clicking a collection reveals its secrets. Secrets that don't belong to any collection appear under an **Uncollected** group. When creating a new secret, you'll be prompted to assign it to a collection — or type a new name to create one on the fly.
## Key Features
### Route Organization
Group related routes together for better project management. All routes in a collection can share common settings and configurations.
### Shared Variables
Define variables once and use them across all routes in the collection. Variables can be:
* **Static values** - Reusable configuration values
* **Secret references** - Point to encrypted secrets for sensitive data
### Environment Defaults
Set default configurations per environment that apply to all routes in the collection:
* **Base URLs** - Default target URLs for each environment
* **Default Clients** - Automatically attach authorized clients to routes
* **Expected Environments** - Auto-provision routes with specific environments
### Centralized Secret Management
Attach secrets to a collection and reference them across all routes, ensuring consistent credential management.
## Use Cases
Group routes for different microservices (auth, payments, notifications) in separate collections, each with their own secrets and environment configurations.
Create collections per client or tenant, with isolated credentials and configuration variables.
Define different base URLs and client permissions per environment (dev, staging, production) at the collection level.
Organize routes by team or project area, making it easier for different teams to manage their own API integrations.
## How Collections Work
When you create a collection, you can:
1. **Add Routes** - Assign existing routes or create new ones directly in the collection
2. **Define Variables** - Create reusable key-value pairs or secret references
3. **Configure Environments** - Set base URLs and default clients per environment
4. **Attach Secrets** - Move secrets into the collection for shared access. Use the inline **Move to Collection** submenu accessible from any secret row in the Secrets list for quick assignment without opening the secret detail.
5. **Apply Defaults** - Bulk-apply environment configurations to all routes
Routes in a collection can reference collection variables using the syntax:
```text theme={"dark"}
{{var:variable_name}}
```
## Benefits
Ensure all related routes use the same credentials and configurations
Easily add new routes that inherit collection settings
Update shared variables and secrets in one place
Streamline multi-environment deployments with collection defaults
## Next Steps
Learn how to create and manage collections
Work with variables and secret references
# Managing Collections
Source: https://docs.knoxcall.com/essentials/collections/managing-collections
Create, configure, and organize your routes with Collections
# Managing Collections
## Creating a Collection
From the main navigation, click **Collections** in the sidebar
Click the **+ Create Collection** button
* **Name**: Enter a descriptive name for your collection
* **Icon**: Choose an icon to visually identify the collection
* **Description**: (Optional) Add notes about the collection's purpose
Click **Create** to save your new collection
You can also create a collection on the fly when creating a new secret. In the secret creation form, type a new name in the collection picker to create and assign a collection in one step — no need to pre-create it.
## Collection Tabs
Each collection has multiple tabs for different aspects of configuration:
### Overview
View and edit basic collection information:
* Collection name and icon
* Description
* Creation date and metadata
### Routes
Manage routes within the collection:
* View all routes in the collection
* Add existing routes
* Create new routes directly in the collection
* View route status (enabled/disabled)
### Secrets
Manage secrets attached to the collection:
* View all secrets in the collection
* Move secrets from other collections
* Create new secrets in this collection
* Secrets can be referenced by collection variables
**Quick move from the Secrets list:** You don't need to open a collection to assign secrets to it. From the main Secrets page, hover over any secret row and use the **Move to Collection** submenu to assign it in-place. This works across collections — you can reassign a secret from one collection to another without opening either.
### Variables
Create and manage reusable variables:
* Define value variables (plain text)
* Define secret variables (reference encrypted secrets)
* Copy variable references to use in routes
* See [Collection Variables](/essentials/collections/collection-variables) for details
### Environments
Configure environment-specific defaults:
* **Expected Environments**: Auto-provision routes with selected environments
* **Base URL**: Default target URL per environment
* **Default Clients**: Automatically attach clients to routes in each environment
* See [Collection Environment Defaults](/essentials/environments/collection-environment-defaults) for details
### Bulk Operations
(Coming soon) Apply changes to all routes in the collection at once.
## Adding Routes to a Collection
### Option 1: During Route Creation
When creating a new route:
1. Fill in the route details
2. In the **Collection** dropdown, select your collection
3. Save the route
The route will automatically inherit any collection environment defaults.
### Option 2: From the Collection Page
1. Navigate to your collection
2. Go to the **Routes** tab
3. Click the **+** button
4. Select **Create new route** or **Add existing route**
### Option 3: From an Existing Route
1. Open any route's detail page
2. Click the **Collection** dropdown
3. Select the target collection
4. Save changes
## Moving Secrets to a Collection
Secrets can be organized by moving them into collections:
Open your collection and go to the **Secrets** tab
Click the **+** button in the Secrets card header
Choose a secret from the list of available secrets
Click **Move to this collection** to complete the transfer
Moving a secret to a collection doesn't break existing routes — secrets can still be used by any route that references them.
## Configuring Environment Defaults
Collections can define default settings that apply to all routes when they're created or updated:
### Setting Expected Environments
1. Go to the **Environments** tab
2. In the **Expected environments** section, click on environment names to toggle them
3. Selected environments will be auto-provisioned for new routes added to the collection
Expected environments are indicated with a checkmark and colored background
### Configuring Per-Environment Defaults
For each environment, you can configure:
Use the environment dropdown to choose which environment to configure
Enter the default target URL for routes in this environment
Select clients that should automatically be attached to routes
Click **Save** to apply the environment configuration
### Applying Defaults to Existing Routes
After configuring environment defaults, you can apply them to existing routes:
1. Configure your environment defaults
2. Review the sync status showing how many routes are out of sync
3. Click **Apply to routes** to bulk-update all routes in the collection
Applying defaults will overwrite the base URL and client assignments for existing routes in that environment. Review carefully before applying.
## Editing Collection Details
1. Navigate to your collection
2. Go to the **Overview** tab
3. Edit the collection name, icon, or description
4. Changes are saved automatically
## Deleting a Collection
Deleting a collection does NOT delete the routes, secrets, or variables within it. They will simply no longer be associated with a collection.
To delete a collection:
1. Navigate to the Collections list
2. Click the **⋯** menu for the collection
3. Select **Delete collection**
4. Confirm the deletion
## Collection Icons
Choose from a wide variety of icons to visually identify your collections:
Folders, Archives, Boxes, Databases
Code, Terminal, Git, Webhooks
Shields, Locks, Keys, Fingerprints
Carts, Cards, Wallets, Receipts
Mail, Messages, Phones, Notifications
Charts, Gauges, Trends, Analytics
...and many more! Select an icon that best represents your collection's purpose.
## Best Practices
Create collections for each distinct project, microservice, or API integration:
* `Stripe Payments`
* `SendGrid Email`
* `Auth0 Authentication`
Establish a naming convention for collections:
* `[Service] - [Environment]` → `Shopify - Production`
* `[Client] - [Integration]` → `Acme Corp - Payments`
* `[Team] - [Project]` → `Platform Team - API Gateway`
Set base URLs and default clients at the collection level to ensure new routes are configured correctly from the start
Move related secrets into the same collection as the routes that use them for easier management
Use the Description field to explain what the collection is for, which team owns it, and any special considerations
## Example Collection Setups
### E-commerce Integration
```text theme={"dark"}
Collection: Shopify Store
Icon: Shopping Bag
Routes: Product Sync, Order Webhook, Inventory Update
Secrets: Shopify API Key, Shopify Webhook Secret
Variables:
- shop_domain = acme-store.myshopify.com
- api_version = 2024-01
Expected Environments: staging, production
```
### Payment Processing
```text theme={"dark"}
Collection: Stripe Payments
Icon: Credit Card
Routes: Create Charge, Refund, List Customers
Secrets: Stripe Secret Key (prod), Stripe Secret Key (test)
Variables:
- stripe_api_key = [Secret: Stripe Secret Key]
- webhook_endpoint = /webhooks/stripe
Expected Environments: sandbox, production
```
### Notification Service
```text theme={"dark"}
Collection: SendGrid Email
Icon: Mail
Routes: Send Email, Send Bulk, Get Templates
Secrets: SendGrid API Key
Variables:
- from_email = noreply@example.com
- default_template_id = d-abc123
Expected Environments: development, production
```
## Next Steps
Learn how to use variables in your routes
Configure environment-specific settings
Add routes to your collection
Create and organize secrets
# Crypto Keys overview
Source: https://docs.knoxcall.com/essentials/crypto-keys/crypto-keys-overview
Encryption-as-a-Service for your application data. Encrypt, decrypt, and rotate keys via API — without your apps ever seeing the key material.
# Crypto Keys overview
KnoxCall's **Crypto Keys** feature is encryption-as-a-service. You create a named key, then encrypt and decrypt data through KnoxCall's API. Your applications **never see the key material** — they call `/encrypt` and `/decrypt` and get back ciphertext / plaintext.
The Vault parallel: this is KnoxCall's `transit` engine.
Use it for:
* **Encrypting customer-record fields** at the application layer (PII, PHI, financial identifiers).
* **Decoupling apps from key storage** — change the key, rotate it, destroy it without touching application code.
* **Cryptographic erasure** — destroy a key version and every piece of ciphertext under it becomes permanently unreadable. Powerful right-to-be-forgotten primitive.
* **Centralised audit** — every encrypt / decrypt call lands in the audit log.
## Quick example
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# Create a named key
curl -X POST https://api.knoxcall.com/v1/crypto/keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "customer-pii", "mode": "cloud-only" }'
# Encrypt some plaintext
curl -X POST https://api.knoxcall.com/v1/crypto/keys/customer-pii/encrypt \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "plaintext": "Jane Doe, 415-555-9311" }'
# → { "ciphertext": "knoxcall:v1:G2x...", "key_version": 1 }
# Decrypt it (any version that hasn't been destroyed)
curl -X POST https://api.knoxcall.com/v1/crypto/keys/customer-pii/decrypt \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ciphertext": "knoxcall:v1:G2x..." }'
# → { "plaintext": "Jane Doe, 415-555-9311", "plaintext_b64": "SmFuZSBEb2UsIDQxNS01NTUtOTMxMQ==", "key_version": 1 }
```
The ciphertext format is `knoxcall:v:` where `N` is the key version that produced it. Vault's `vault:v:...` prefix is also accepted on input — you can migrate ciphertext stored under Vault's transit engine without re-encrypting.
## Algorithms
Pick a `key_type` when you create a key. The right choice depends on what you'll use it for:
| `key_type` | Use case | Operations |
| - | - | - |
| `aes256-gcm` | Encrypt/decrypt application data | `encrypt`, `decrypt`, `rewrap` |
| `hmac-sha256`, `hmac-sha512` | HMAC over a payload (request signing, webhook auth) | `hmacSign`, `hmacVerify`, `signJwt`, `verifyJwt` |
| `rsa-2048`, `rsa-3072`, `rsa-4096` | Asymmetric signing — JWTs (RS256/PS256), document signing | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` |
| `ecdsa-p256`, `ecdsa-p384` | Asymmetric signing — JWTs (ES256/ES384), faster than RSA | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` |
| `ed25519` | Modern asymmetric signing — JWTs (EdDSA), constant-time | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` |
**Cryptographic primitives:**
* AES-256-GCM. Ciphertext payload `iv[12] || authTag[16] || ciphertext[n]`. Format-prefix `knoxcall:v:`.
* HMAC: SHA-256 (32-byte tag) or SHA-512 (64-byte tag).
* RSA: PKCS#1 v1.5 padding by default for JWT compatibility (RS256). PSS available via `rsa_padding: 'pss'` for raw signing, or `header_overrides: { alg: 'PS256' }` (/ `PS384`, `PS512`) for JWT signing.
* ECDSA: deterministic per RFC 6979.
* Ed25519: standard EdDSA.
**Public key export:** asymmetric keys expose `GET /v1/crypto/keys/{name}/public-key` returning both PEM (SPKI) and JWK. External services verify your signatures using the public key without ever needing the private side.
## JWT signing and verification
Use a key directly to sign / verify JWTs:
```bash theme={"dark"}
# Sign
curl -X POST https://api.knoxcall.com/v1/crypto/keys/jwt-signer/jwt \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "claims": { "sub": "user-123", "exp": 9999999999 } }'
# → { "data": { "token": "eyJ...", "key_version": 1, "alg": "RS256" }, "meta": { "request_id": "..." } }
# Verify
curl -X POST https://api.knoxcall.com/v1/crypto/keys/jwt-signer/jwt/verify \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "token": "eyJ..." }'
# → { "data": { "valid": true, "claims": { ... }, "key_version": 1, "alg": "RS256" }, "meta": { "request_id": "..." } }
```
The `alg` is derived from the key (`hmac-sha256` → `HS256`, `rsa-2048` → `RS256`, `ecdsa-p256` → `ES256`, `ed25519` → `EdDSA`). The verifier:
* **rejects `alg: 'none'`** unconditionally,
* **rejects algorithm confusion** — an `HS256` token won't verify against an RSA key,
* **binds `kid`** so the version that issued the token is the one used to verify,
* **validates standard claims** when you pass `expected` (issuer, audience, subject, expiry, clock skew).
**External verify** (the killer test): export the public key, hand it to `jsonwebtoken` in Node or `pyjwt` in Python, and verify a token KnoxCall signed. If those libraries accept it, the implementation is end-to-end correct.
## Webhook signing helper
For Stripe-format webhook signatures (v1 supports the Stripe `t=,v1=` format; additional formats are planned), use the helper:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/crypto/keys/stripe-webhook-secret/webhook-sign \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "payload": "{\"event\":\"order.completed\"}" }'
# → { "data": { "signature_header": "t=1735000000,v1=", "timestamp_seconds": 1735000000, "key_version": 1, "format": "stripe" }, "meta": { "request_id": "..." } }
```
This is the same signing logic [Webhook Signing](/essentials/webhook-signing/overview) uses for outbound subscriptions, exposed as a primitive so you can sign payloads with full control over headers and timing.
## Modes
When you create a key, pick a **mode**:
### `cloud-only` (recommended)
Key material stays on KnoxCall's control plane. Every encrypt / decrypt is a network call.
* ✅ Strongest blast-radius story — a self-hosted agent or local app process can never leak the key.
* ⚠️ Higher latency — every operation is a round-trip to KnoxCall.
* 👍 Use for: high-value secrets (PHI, financial identifiers, credentials), audit-driven encryption (every call is logged), keys you might ever want to revoke.
### `bundled`
Key material is shipped to self-hosted agents in their session bundle (encrypted with the session key) so the agent can do encrypt / decrypt **locally**, without the call leaving the customer's network.
* ✅ Low-latency local encrypt / decrypt.
* ✅ Same trust model as KnoxCall's secret-injection feature today — in-memory, hour-bounded.
* ⚠️ A compromised agent process can read the in-memory key for the duration of its session.
* 👍 Use for: hot-path encryption inside a trusted self-hosted environment where round-trips would be too slow.
The mode is a one-shot decision per key — to switch, create a new key in the other mode and `rewrap` ciphertext to it.
## Operations
| Op | What it does | When to use |
| - | - | - |
| `encrypt` | Plaintext → ciphertext, tagged with current version | Every encryption op |
| `decrypt` | Ciphertext → plaintext, using whatever version the prefix names | Every decryption op |
| `rotate` | Mints a new active version, retires the previous | Periodic key hygiene; after a suspected compromise |
| `rewrap` | Decrypts with old version + re-encrypts with active. **Plaintext never crosses the wire.** | Migrating old ciphertext to a fresh version after a rotate |
| `destroyVersion` | Cryptographic erasure of a specific version | Right-to-be-forgotten; after rewrap migration is done |
| `hmacSign` / `hmacVerify` | Keyed HMAC over a payload | API request signing, message authentication |
| `sign` / `verify` | Asymmetric sign / verify (RSA, ECDSA, Ed25519) | Document signing, custom-format signing |
| `signJwt` / `verifyJwt` | Issue or validate a JWT | Service-to-service auth, customer-facing tokens |
| `webhookSign` | Stripe-format `t=...,v1=...` header | Outbound webhook signing primitive |
| `getPublicKey` | Export the public side of an asymmetric key (PEM + JWK) | Hand to external verifiers |
## Versioning at a glance
```text theme={"dark"}
customer-pii (current_version = 3)
v3 active ← encrypt uses this
v2 retired ← can still decrypt v2 ciphertext
v1 destroyed ← v1 ciphertext is permanently unreadable
```
* **Encrypt** always uses the active version.
* **Decrypt** uses the version named in the ciphertext prefix.
* **Retired** versions stay around so legacy ciphertext keeps working.
* **Destroyed** versions are cryptographic erasure — that ciphertext is permanently unreadable. (You can't destroy the active version; rotate first.)
`deletion_allowed` is a per-key safety latch, `false` on every new key. A version can only be destroyed while it is `true`, so an erasure nothing can undo is never one call away. Raise it with `PATCH /v1/crypto/keys/` (or the **Allow version destruction** toggle on the key's Versions tab) and lower it again straight after — see [Rotation & versioning](/essentials/crypto-keys/rotation-and-versioning#deletion-allowed).
See [Rotation & versioning](/essentials/crypto-keys/rotation-and-versioning) for the full lifecycle.
## How the keys themselves are protected
Each version is stored wrapped with your [tenant master key](/essentials/key-management/tenant-master-key) (KCT1 envelope format). When KnoxCall needs to use a version, it unwraps it on the fly into an in-memory cache (5-minute TTL).
If you cryptographically erase your **tenant master key**, every transit key under it becomes permanently unreadable — by construction. There's no separate "delete all keys" step; the wrapping chain does it.
## Audit trail
Every encrypt / decrypt / rotate / destroy lands in the audit log under `resource_type='transit'` with the key name, version, and caller. You can answer "who decrypted PII record 12345 last Tuesday?" in two log queries.
## Plays well with...
* **Routes** — store an encrypted secret in a route header by encrypting it with a transit key and decrypting on egress.
* **Databases** — encrypt PII in your own DB schema; decrypt only on read.
* **[Vaults](/essentials/vaults/overview)** — every vault gets its own AES-256-GCM Crypto Key. Rotate the vault → rotates the underlying transit key.
* **[Webhook Signing (outbound)](/essentials/webhook-signing/overview)** — sign payloads with `hmacSign` (HMAC formats) or `sign` (RSA/ECDSA/Ed25519). `webhookSign` returns a Stripe-shaped header in one call.
* **[Inbound Webhooks](/essentials/inbound-webhooks/overview)** — point a subscription at an HMAC key here instead of pre-sharing a secret; rotation flows through the Crypto Keys lifecycle.
* **[Ephemeral Proxy](/essentials/ephemeral-proxy/overview)** — `{{ encrypted | json: ... }}` template references encrypt small JSON blobs with a Crypto Key.
## Who can decrypt and rewrap
Decrypting with a key, and rewrapping a ciphertext onto the key's latest version (which opens the
ciphertext on the server to re-seal it), are reveals. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission. A token issued to a person never carries `transit:decrypt`; transit rewrap on `/v1` is unchanged and does not take this check. Encrypting, signing and verifying are
not reveals and are unchanged.
## Next steps
* [Rotation & versioning →](/essentials/crypto-keys/rotation-and-versioning)
* [Tenant master key →](/essentials/key-management/tenant-master-key)
* [API reference →](/api-reference/overview)
# Rotation and versioning
Source: https://docs.knoxcall.com/essentials/crypto-keys/rotation-and-versioning
How crypto-key versions move from active to retired to destroyed, and the rewrap flow that lets you migrate old ciphertext without ever exposing plaintext.
# Rotation and versioning
Every crypto key in KnoxCall is **versioned**. New encryptions always use the latest active version; old ciphertext keeps decrypting against whatever version originally produced it. Rotation moves the active pointer forward without breaking anything that's already encrypted.
## The version lifecycle
```text theme={"dark"}
v1 active ──rotate──▶ v1 retired + v2 active ──rotate──▶ v2 retired + v3 active ──destroyVersion──▶ v1 destroyed
```
Each version is in one of three states:
* **`active`** — used for new encryptions. Exactly one per key at any time.
* **`retired`** — kept around so old ciphertext under that version still decrypts. As many as you've rotated.
* **`destroyed`** — cryptographic erasure. The wrapped key bytes are wiped and the row stays as an audit marker. Ciphertext under a destroyed version is **permanently unreadable**.
## Rotating a key
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/crypto/keys/customer-pii/rotate \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID"
# → { "new_version": 2 }
```
What happens, atomically inside a single transaction:
1. A new 256-bit key is generated and wrapped with the tenant master key.
2. The new version row is inserted as `active`.
3. The previous active version is flipped to `retired`.
4. The key's `current_version` pointer is updated.
`encrypt` calls from this point forward emit `knoxcall:v2:...` ciphertext. `decrypt` calls handle either `v1` or `v2` ciphertext seamlessly.
## When to rotate
| Reason | How often |
| - | - |
| Routine hygiene (compliance) | Every 90 days |
| After a personnel change with key access | Immediately |
| After a suspected compromise | Immediately + rewrap + destroy old version |
| Pre-emptive before destroying the active version | Always (you can't destroy `active`) |
Rotating is cheap. Decrypting from old versions is also cheap. The only meaningful "cost" of holding many retired versions is audit-trail clutter.
## Rewrap: migrate ciphertext to a new version
Rotating doesn't re-encrypt existing data. Old ciphertext keeps decrypting against retired versions. To migrate it:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/crypto/keys/customer-pii/rewrap \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{ "ciphertext": "knoxcall:v1:G2x..." }'
# → { "ciphertext": "knoxcall:v2:K9p...", "key_version": 2 }
```
`rewrap` decrypts the old ciphertext with the retired version, re-encrypts with the active version, and returns the new ciphertext — **the plaintext never crosses the wire**. The operation runs entirely on KnoxCall's side.
The typical migration loop:
```text theme={"dark"}
for each row in your_table where ciphertext_version < current_version:
new_ct = POST /admin/crypto/keys//rewrap { ciphertext: row.ct }
UPDATE your_table SET ct = new_ct WHERE id = row.id
```
After every row is rewrapped, you can safely destroy the old version.
## Destroying a version (cryptographic erasure)
```bash theme={"dark"}
curl -X DELETE https://api.knoxcall.com/admin/crypto/keys/customer-pii/versions/1 \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID"
```
What this does:
1. Refuses with `409 deletion_not_allowed` if `deletion_allowed = false` on the parent key (the safety latch — see below for how to raise it).
2. Refuses with `409 active_version` if the version is `active` — rotate first.
3. Wipes the wrapped key bytes from the row, flips status to `destroyed`, records who and when.
Any ciphertext that named this version in its prefix is **gone forever**. There's no off-site backup. This is the feature, not a bug — it's the right-to-be-forgotten primitive.
## `deletion_allowed`
Every key ships with `deletion_allowed = false`. Flipping it to `true` is a deliberate, audit-logged action that says "I'm about to destroy a version on purpose."
Flip it from the API:
```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/crypto/keys/customer-pii \
-H "Authorization: Bearer $KC_TOKEN" \
-H "Content-Type: application/json" \
-d '{"deletion_allowed": true}'
```
…or from the admin UI: **Crypto Keys → your key → Versions → Allow version destruction**. The UI toggle requires a recent second-factor verification; the `/v1` call is bounded by the credential's `transit:manage` grant instead, because there is no human on the machine plane to re-authenticate.
Pattern we recommend:
1. Rewrap all data to the new active version.
2. Confirm in a query that no row in your DB still has ciphertext at the version you're about to destroy.
3. `PATCH /v1/crypto/keys/` with `{"deletion_allowed": true}`.
4. `DELETE /versions/`.
5. `PATCH` it back to `{"deletion_allowed": false}`.
Every flip writes a `transit.key.deletion_allowed` audit row recording the previous and new value, so "who armed this key, and when" is answerable after the fact.
Before 2026-09, this latch had no writer on any surface: it was `false` for every key, the
admin UI's Destroy button was therefore never shown, and a `DELETE` of any version answered
`500`. Both are fixed — the refusal is now a `409` with a
`deletion_not_allowed` code, and the latch is settable on both surfaces as above.
## What rotation doesn't do
* It doesn't break existing decryption — old ciphertext keeps working until you destroy the old version.
* It doesn't re-encrypt existing data — that's `rewrap`.
* It doesn't change the ciphertext format — same prefix, just a higher version number.
* It doesn't propagate to bundled-mode agents instantly — they pick up the new version on their next session-bundle refresh.
## Key cache behaviour
Unwrapped key versions are cached in memory for **5 minutes** after first use. Rotation invalidates the previous active entry; destroy invalidates the destroyed entry. You don't need to worry about the cache day-to-day — it's an internal performance optimisation, not something you manage.
## Audit log markers
| Audit action | Means |
| - | - |
| `transit.key.create` | A new key was minted (version 1 active) |
| `transit.key.rotate` | New version became active |
| `transit.encrypt` / `transit.decrypt` | Single op (records key version, not plaintext) |
| `transit.rewrap` | Ciphertext was rewrapped from one version to another |
| `transit.key.destroy_version` | Version was cryptographically erased |
## See also
* [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview)
* [Tenant master key →](/essentials/key-management/tenant-master-key) — the wrap chain that protects every transit key
# Authentication modes
Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/auth-modes
Pick how KnoxCall reaches your database: direct, agent tunnel, SSH tunnel, or AWS RDS IAM.
# Authentication modes
When you register a database, you pick one of four **execution modes** that decides how KnoxCall actually talks to it. The right pick depends on where the database lives and how you want credentials to flow.
## Quick decision guide
```text theme={"dark"}
Is the DB publicly reachable on the internet?
├─ Yes → "Direct"
└─ No
├─ Is it AWS RDS / Aurora with IAM auth enabled?
│ └─ Yes → "IAM" (no admin password needed, 15-min tokens)
├─ Do you have a KnoxCall agent running in the same VPC/network?
│ └─ Yes → "Agent tunnel" (DSN never leaves your network)
└─ Do you have an SSH host that can reach the DB?
└─ Yes → "SSH tunnel"
```
## Direct
KnoxCall opens a TCP connection straight from the control plane to your database, authenticates with the admin credential you registered, and runs `CREATE USER` / `DROP USER` SQL against the DB.
**Use it when:**
* Your DB is publicly reachable (or KnoxCall's IP is allow-listed in your DB firewall).
* TLS is already in place (default `sslmode=require`).
* You're OK with the admin credential leaving the customer network on each issue/revoke.
**Required fields:** host, port, admin username, admin password.
## Agent tunnel
You install a small KnoxCall **agent** inside your VPC. The agent maintains an outbound WebSocket to the control plane. When credentials need minting, KnoxCall pushes a `db_run_sql` command through that WebSocket; the agent runs the SQL **inside your network** and reports back.
**The DSN and admin password are pushed just-in-time on each mint** — they don't sit on the agent.
**Use it when:**
* Your DB isn't publicly reachable and you don't want to open an inbound firewall hole.
* You want the strongest "DSN never leaves my VPC" story (this is the structural advantage over self-hosted Vault, which has to reach to the database).
* You already run a KnoxCall agent for other features.
**Required fields:** host, port, admin username, admin password, agent\_id.
`agent_id` must name an agent registered to your tenant **in the same
environment** as the key or session creating the connection. A Test-mode
caller cannot bind a connection to a Live agent, and vice versa — the
connection is rejected with `400 validation_error`, and a mint against a
connection whose agent lives in the other environment is refused at dispatch.
Test credentials are the ones you hand to CI and to contractors; they do not
reach the agent running inside your production network.
## SSH tunnel
KnoxCall opens an SSH connection to a customer-supplied SSH host (a jump server, bastion, or any reachable Linux box that can see the DB), uses `forwardOut` to tunnel a TCP connection to your DB, and then runs SQL through that tunnel.
The SSH private key is encrypted with your tenant master key and stored alongside the connection. The matching public key lives on the SSH host in `~/.ssh/authorized_keys` — KnoxCall is the **client**, just like any developer's laptop.
**Use it when:**
* Your DB isn't publicly reachable, you don't (yet) run a KnoxCall agent, but you have a bastion or jump host.
* You'd rather manage `authorized_keys` than another moving part.
**Required fields:** SSH host, SSH username, SSH private key (PEM), DB host (resolved from inside the SSH host's network), admin username, admin password. Optional: SSH port (default 22), key passphrase.
**Engine support:** Postgres, MySQL, and MongoDB.
## IAM (AWS RDS / Aurora)
For RDS Postgres or RDS / Aurora MySQL clusters with IAM database authentication enabled. KnoxCall uses the AWS RDS Signer SDK to mint a 15-minute auth token for an existing IAM-enabled DB user — **no admin password is stored, no dynamic CREATE USER runs**.
The DB user is one you've already provisioned and granted IAM auth on:
```sql theme={"dark"}
-- Postgres
GRANT rds_iam TO app_user;
```
```sql theme={"dark"}
-- MySQL (Aurora / RDS)
CREATE USER 'app_user' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS';
```
KnoxCall's control-plane needs an AWS role with `rds-db:connect` on the cluster's user ARN.
**Use it when:**
* You're on RDS and want to skip storing admin passwords entirely.
* Your application already supports IAM auth.
**Required fields:** RDS endpoint host, AWS region, IAM-enabled DB user. No admin password, no port (defaults to engine default).
**Lease ceiling:** AWS pins auth tokens to 15 minutes regardless of the lease TTL you ask for. KnoxCall caps the lease at that ceiling.
## Comparison
| | Direct | Agent tunnel | SSH tunnel | IAM |
| - | - | - | - | - |
| **DB needs public address?** | Yes | No | No (only the SSH host does) | Yes (RDS endpoint) |
| **Admin password stored?** | ✅ encrypted | ✅ encrypted | ✅ encrypted | ❌ none |
| **Extra infra to run?** | None | KnoxCall agent | An SSH host you control | An AWS IAM role |
| **DSN leaves your network?** | Yes (during mint) | No | Yes (over SSH-encrypted tunnel) | No (token signed in AWS) |
| **Engines** | pg / mysql / mongo | pg / mysql / mongo | pg / mysql / mongo | postgres / mysql |
| **Lease TTL ceiling** | Customer-set | Customer-set | Customer-set | 15 min (AWS-pinned) |
## Testing before you save
Every New Database page has a **Test** button that runs the smallest possible probe for the selected mode:
* Direct / SSH tunnel — opens a real connection and runs `SELECT 1` (or `ping` for Mongo).
* Agent tunnel — dispatches `SELECT 1` through the agent's control-WS.
* IAM — calls RDS Signer once and confirms a token was minted.
Create is gated on a successful test. Any field change after a successful test invalidates it — you have to re-test before saving.
# Lease lifetime
Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/leases
Every credential KnoxCall mints is tracked as a lease that auto-expires. Learn how TTLs are resolved, capped, and revoked.
# Lease lifetime
Every database credential KnoxCall mints is tracked as a **lease**. The lease records who got the credential, what role it was minted from, when it was issued, and — most importantly — **when it expires**.
When the lease expires, KnoxCall automatically runs the role's `revocation_sql` against the database and drops the user. The customer doesn't have to remember to clean up.
## Two TTL fields
Each connection (and each role) has two TTL settings:
* **Default TTL** — the duration applied when the caller doesn't ask for a specific TTL. Most "give me a credential" calls don't specify, so this is the typical case.
* **Max TTL** — the absolute ceiling. A caller can ask for less, but never more.
```text theme={"dark"}
default_ttl_seconds: 3600 # 1 hour — the default
max_ttl_seconds: 86400 # 24 hours — the ceiling
```
A request like `POST /v1/dyn-db-credentials/{name}/creds/{role}` with no `ttl_seconds` mints a 1-hour credential. A request asking for `ttl_seconds=43200` (12h) mints a 12-hour credential. A request asking for `ttl_seconds=172800` (48h) is silently clamped to the 24h ceiling — it doesn't fail.
## Resolution order
When a credential is minted, the TTL is resolved in this order:
1. **Caller-requested** TTL (if provided and > 0).
2. **Role-level** default (overrides connection default, if set).
3. **Connection-level** default (the fallback).
Then the result is clamped to `min(role.max_ttl, connection.max_ttl)`.
So if you have `connection.max_ttl=86400` and `role.max_ttl=7200`, the role-level cap wins — that role can never mint a credential longer than 2 hours, no matter what the caller asks for.
This lets you define a strict role for prod (`max_ttl=900` — 15 minutes) sitting on a connection with a more generous default (`max_ttl=86400`) used by other roles.
## How revoke works
There are two paths to credential revocation:
### 1. Automatic (on expiry)
A background **lease worker** scans for expired leases on a regular tick. When it finds an expired lease, it calls the executor's `revoke()` — which runs the role's `revocation_sql` template substituted with the username — and flips the lease's status to `expired`.
You can see expired leases in the audit log: action `db_credential.revoke` with `executor_ok: true`.
### 2. Explicit (manual)
`POST /v1/dyn-db-credentials/leases/{lease_id}/revoke` (or click **Revoke** in the admin UI) does the same thing on demand. The lease ends up in `revoked` status rather than `expired` — useful for distinguishing "this was killed because someone leaked it" from "this aged out normally."
## IAM mode is special
For [IAM mode](/essentials/dyn-db-credentials/auth-modes#iam-aws-rds-aurora), KnoxCall doesn't create a dynamic DB user — it mints a 15-minute AWS auth token for an existing IAM-enabled user. There's no `DROP USER` to run on revoke; the token simply stops working.
Implications:
* **Lease TTL is capped at 15 minutes** regardless of your `default_ttl_seconds` / `max_ttl_seconds` settings.
* The lease worker still flips state at expiry for audit-trail completeness, but `executor.revoke()` is a no-op.
* Explicit revoke also flips state but doesn't actively kill the token — you can't kill an AWS-signed token mid-flight; you wait for it to expire.
If you need fast kill-switch behaviour on RDS, layer on a security-group revoke or AWS IAM policy detach. KnoxCall's lease state isn't a substitute for those.
## Recommended values
| Use case | Default TTL | Max TTL |
| - | - | - |
| Engineer ad-hoc query | 1h (`3600`) | 8h (`28800`) |
| CI / scheduled job | 15m (`900`) | 1h (`3600`) |
| Long-running migration | 4h (`14400`) | 8h (`28800`) |
| Contractor offboarding | duration = engagement | same |
| High-trust prod read-write | 5m (`300`) | 15m (`900`) |
Shorter is always safer. The friction cost of re-issuing is one API call.
## Reading lease state in the UI
Open a connection on the **Dynamic DB Credentials** page to see its **Live leases** panel. Each row shows:
* The minted username (also the lease's subject ID — useful for grepping audit logs).
* The role it came from.
* When it expires.
* A **Revoke** button.
The list shows every **live** lease — one whose database user still exists on your server. That means `active`, `renewing` and `errored`. Once a lease moves to `expired` or `revoked` its user has been dropped, so it falls off this view but remains in audit history.
`errored` is on the list deliberately, and it is the row to look at first: KnoxCall gave up renewing that lease after five consecutive failures, so nothing is refreshing or expiring it and **the database user is probably still live**. Revoke it to drop the user. It is also the reason a delete can be refused — a connection or role with a live lease returns `409` (see below), and `errored` counts.
## Listing leases over the API
`GET /v1/dyn-db-credentials/leases` returns the same set the UI panel shows — **live leases only**, meaning `status` `active`, `renewing` or `errored`. A lease that has `expired` or been `revoked` no longer has a database user, so it is neither listed nor counted in `total`; its history stays in the audit log.
Each row names the database it belongs to: `connection_name` and `engine` come from the connection, `role_name` from the role it was minted from, and `username` is the account on the customer's server.
```bash theme={"dark"}
curl -H "Authorization: Bearer $TOKEN" "https://api.knoxcall.com/v1/dyn-db-credentials/leases?connection=postgres-prod&limit=50"
```
`connection` is an exact match on the connection's name and is scoped to the calling key's Live/Test space — a `tk_test_` key never sees a Live lease, and never sees one by naming a Live connection. Pagination is `limit`/`offset`, and both, along with `total`, are returned **inside** `data`.
## "has N active credential lease(s)" — deleting a connection or role
Deleting a connection or a role is refused while any of its leases is live:
```json theme={"dark"}
{ "error": { "code": "conflict",
"message": "connection 'postgres-prod' has 2 active credential lease(s); revoke them before deleting so their database users can be dropped" } }
```
This is on purpose. Revoking a credential means `DROP USER` on *your* database, and reaching it needs the connection's host and admin credential — or, for a role, its revocation SQL template. Delete those first and the leftover database users are stranded with nothing left that can drop them.
**The N in that message is exactly the set this page lists.** It is the same definition of live — `active`, `renewing`, `errored` — used by the delete check, the UI panel and `GET /v1/dyn-db-credentials/leases`, so anything blocking your delete is visible with a Revoke button next to it. Revoke them, then retry the delete.
# Dynamic DB Credentials overview
Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/overview
Mint short-lived database credentials on demand. Replaces long-lived service-account passwords with auth that auto-expires.
> **Renamed 2026-05-03.** This feature was previously called **Databases**.
> The old `/v1/databases/*` and `/admin/databases/*` paths now return 404 —
> use `/v1/dyn-db-credentials/*` and `/admin/dyn-db-credentials/*`.
> The "Databases" name has been freed for the upcoming **Database Proxy**
> product (a TCP-level Postgres / MySQL wire-protocol proxy).
# Dynamic DB Credentials overview
KnoxCall's **Dynamic DB Credentials** feature lets you mint short-lived database credentials on demand — for engineers, CI pipelines, scheduled jobs, or any workload that needs temporary DB access without sharing a long-lived password.
You register a database connection once. After that, callers ask KnoxCall for a credential whenever they need one. KnoxCall mints a fresh username + password (or an AWS auth token), tracks it as a **lease**, and automatically revokes it at expiry.
## Why use it
| Problem | Without KnoxCall | With KnoxCall |
| - | - | - |
| Engineers share a Slack-pinned `prod_password` | Anyone who joins/leaves the team triggers a rotation | Each engineer mints their own 1-hour credential |
| CI pipeline holds a service-account secret | Leak = full DB compromise until rotation | Leak = ≤ TTL until auto-revoke |
| Audit trail of "who ran what against prod" | Best-effort, by IP | Per-credential, per-lease, in audit log |
| Onboarding a contractor for 2 weeks | Manual create + reminder to delete | Set TTL = 14 days, drops itself |
## How it works
1. **Register a connection** — point KnoxCall at your database (Postgres, MySQL, or MongoDB). Pick one of four [authentication modes](/essentials/dyn-db-credentials/auth-modes).
2. **Define a role** — an SQL template that describes the kind of user to mint (read-only, read-write, or your own template). Built-in `readonly` / `readwrite` templates work for most setups.
3. **Mint a credential** — call `POST /v1/dyn-db-credentials/{name}/creds/{role}` with an OAuth access token (or legacy API key — see [Authentication](/api-reference/authentication)). Get back a username + password + expiry.
4. **Use it** — connect with the returned credentials like any other DB user.
5. **Forget it** — at the [lease's expiry](/essentials/dyn-db-credentials/leases), KnoxCall drops the user automatically. Or revoke it explicitly with `POST /v1/dyn-db-credentials/leases/{id}/revoke`.
## Quick start (UI)
1. Go to **Dynamic DB Credentials** in the admin UI and click **New Connection**.
2. Fill in the connection details. **Test** the connection — KnoxCall verifies it can reach your DB before letting you save.
3. After creation, expand the connection and click **Register role** → pick `readonly` or `readwrite`.
4. Click **Mint credential** to issue your first short-lived user.
## Quick start (API)
All responses use the standard `{ data: ..., meta: { request_id: "..." } }` envelope. The examples below show the `data` fields.
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# 1. Register a connection
curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "postgres-prod",
"engine": "postgres",
"host": "db.example.internal",
"port": 5432,
"admin_username": "kc_admin",
"admin_password": "...",
"execution_mode": "direct",
"default_ttl_seconds": 3600,
"max_ttl_seconds": 86400
}'
# 2. Register a role (built-in readonly template)
curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials/postgres-prod/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "analytics_ro", "template": "readonly" }'
# 3. Mint a credential
curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials/postgres-prod/creds/analytics_ro \
-H "Authorization: Bearer $TOKEN"
# → data: { "username": "kc_user_...", "password": "...", "expires_at": "...",
# "lease_id": 42, "connection_name": "postgres-prod", "role_name": "analytics_ro" }
```
## Supported engines
| Engine | API value | Direct | Agent tunnel | SSH tunnel | IAM (RDS) |
| - | - | - | - | - | - |
| PostgreSQL | `postgres` | ✅ | ✅ | ✅ | ✅ |
| MySQL | `mysql` | ✅ | ✅ | ✅ | ✅ |
| MongoDB | `mongo` | ✅ | ✅ | ✅ | ❌ (not RDS) |
See [authentication modes](/essentials/dyn-db-credentials/auth-modes) for what each mode means and when to pick it.
## Built-in role templates
| Template | Postgres | MySQL | MongoDB |
| - | - | - | - |
| `readonly` | `GRANT SELECT ON ALL TABLES` | `GRANT SELECT` | `read` role |
| `readwrite` | `GRANT SELECT, INSERT, UPDATE, DELETE` | `GRANT SELECT, INSERT, UPDATE, DELETE` | `readWrite` role |
Both templates create a user with the password KnoxCall generates and grant just enough permissions for the named scope. If neither fits, supply your own `creation_sql` + `revocation_sql`.
## Who can issue a credential
Issuing a credential from the dashboard creates a live username and password on your database, so it
is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission, and a token issued to a person never carries `dyn_db_cred:mint`. Registering connections and roles and revoking leases are unchanged.
## Next steps
* [Authentication modes →](/essentials/dyn-db-credentials/auth-modes)
* [Lease lifetime →](/essentials/dyn-db-credentials/leases)
* [API reference →](/api-reference/overview)
# Browser encryption & UI Components
Source: https://docs.knoxcall.com/essentials/encryption/browser
Seal sensitive values in the browser so plaintext never reaches your servers. Client-side encryption, single-use reveal tokens, and iframe-isolated components.
# Browser encryption & UI Components
Encrypt sensitive values **in the page**, against your tenant's public key, so plaintext (an SSN, a bank account, a national ID) never touches your servers. You store and transmit only a portable [`kc:` ciphertext](/essentials/encryption/ciphertext-spec) — and only KnoxCall, holding the private key, can decrypt it. No API key is ever shipped to the browser.
**This is not a payment-card control.** Sealing happens in *your* page's
JavaScript, so the plaintext is in a context your own site controls — that is
the opposite of what a hosted-fields component does, and it reduces no
cardholder-data scope. KnoxCall also holds no PCI DSS attestation. Use it for
PII and secrets; use your processor's hosted fields for cards.
## 1. Client-side encryption (`@knoxcall/browser`)
Your backend fetches a **sealing bundle** (the tenant public key + a key reference — no private material) and hands it to the page. Encryption is then a pure client-side operation with no network round-trip.
```ts theme={"dark"}
// --- your backend (holds the KnoxCall credential) ---
// GET /v1/encrypt/sealing-bundle?key=payments -> { public_key_raw, key_ref }
import { KnoxCall } from '@knoxcall/sdk';
const knox = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
app.get('/checkout/sealing-bundle', async (req, res) => {
res.json(await knox.crypto.getSealingBundle({ key: 'payments' }));
});
```
```ts theme={"dark"}
// --- the browser ---
import { KnoxEncryptor } from '@knoxcall/browser';
const bundle = await fetch('/checkout/sealing-bundle').then((r) => r.json());
const enc = new KnoxEncryptor(bundle, { purpose: 'pci' });
const ciphertext = await enc.encrypt(cardNumber); // "kc:1:s:…:$"
// send `ciphertext` to your backend / KnoxCall — the PAN never left the page.
```
The scheme (ephemeral ECDH P-256 → HKDF-SHA256 → AES-256-GCM) is byte-identical to the server, so any `kc:` value you produce here decrypts through `/v1/decrypt`, a [Relay route-action](/essentials/routes/what-are-routes), or a data-plane enclave.
## 2. Reveal to the browser without an API key
To show a user one decrypted value, your backend mints a **single-use, payload-pinned capability token** (see [client-side reveal](/essentials/encryption/overview#client-side-reveal-without-an-api-key)) and the page exchanges it once:
```ts theme={"dark"}
import { KnoxClient } from '@knoxcall/browser';
const knox = new KnoxClient();
const pan = await knox.reveal(capabilityToken, ciphertext); // one-time
// or, for a vault token:
const value = await knox.detokenize(capabilityToken, 'tok_…');
```
The token is consumed server-side on first use, expires in minutes, and only matches the exact value it was minted for.
## 3. Iframe UI Components (`@knoxcall/react`)
For the strongest isolation, capture and display data inside a **cross-origin iframe** served from a KnoxCall origin. The host page — and any XSS on it — never sees the raw PAN; your callback receives only the `kc:` ciphertext plus non-sensitive display bits (last4/brand).
```tsx theme={"dark"}
import { CardCollect } from '@knoxcall/react';
setReady(complete)}
onToken={(ciphertext, { last4, brand }) => {
// store the ciphertext; show `•••• last4` to the user.
}}
/>
```
Every message from the iframe is **origin-checked (exact match), source-checked (must be the KnoxCall iframe), and shape-validated** before your app trusts it — that logic lives in the shared, unit-tested protocol.
`` is the mirror: it decrypts (or detokenizes) with a capability token and displays the value *inside* the iframe, so plaintext is shown to the user but never crosses into your JavaScript.
## Which to use
| You need | Use |
| - | - |
| Seal a value your own form already holds | `KnoxEncryptor.encrypt()` |
| Never let your JS touch the PAN at all | `` (iframe) |
| Show one decrypted value to a user | capability token + `KnoxClient.reveal()` or `` |
| Decrypt server-side / in transit | [`/v1/decrypt`](/essentials/encryption/overview) or [Relay route-actions](/essentials/routes/what-are-routes) |
# kc: ciphertext format & cryptographic spec
Source: https://docs.knoxcall.com/essentials/encryption/ciphertext-spec
The exact wire format and cryptographic construction behind KnoxCall's portable kc: ciphertext — ECDH P-256, HKDF-SHA256 with context binding, and AES-256-GCM. Published in full so your security reviewers can audit it.
# kc: ciphertext format & cryptographic spec
We publish the exact construction of the `kc:` ciphertext — algorithms, parameters, and key hierarchy — so that a security reviewer can evaluate it without a sales call. Specificity is the point: you should be able to verify what we built, not take our word for it.
## Wire format
A `kc:` value is a single ASCII string. Each variable segment is **base64url** (no padding):
```
kc:::::::$
```
| Segment | Meaning |
| - | - |
| `version` | Scheme version (integer). Drives crypto-agility — the construction can change under a new version without breaking stored data. Currently `1`. |
| `datatype` | One of `s` (string), `n` (number), `b` (boolean), `j` (JSON), `x` (raw bytes). Lets decrypt round-trip the original type. |
| `key_ref` | `"::"` — names the recipient keypair so decrypt needs **zero** out-of-band metadata. |
| `eph_pubkey` | The ephemeral ECDH public key as a 65-byte uncompressed P-256 point (`0x04 ‖ X ‖ Y`). |
| `iv` | 12-byte AES-GCM nonce. |
| `ciphertext‖tag` | AES-256-GCM output with the 16-byte authentication tag appended. |
| `$` | Terminator — guards against trailing-data and makes the string recognisable by eye and by tooling. |
The leading `kc:` and trailing `:$` make detection cheap and unambiguous (`POST /v1/decrypt` passes non-`kc:` values through untouched on this basis).
## Cryptographic construction (version 1)
Per-value **ECIES** on the NIST P-256 curve:
1. Generate an **ephemeral P-256 keypair** for this value.
2. Compute the ECDH shared secret `Z` between the ephemeral private key and the recipient (tenant app) public key.
3. Derive the content-encryption key:
```
CEK = HKDF-SHA256(ikm = Z, salt = eph_pubkey, info = AAD, length = 32 bytes)
```
4. Encrypt with **AES-256-GCM** (12-byte random IV, 16-byte tag). The `kc:` header (version, datatype, key\_ref, eph\_pubkey, iv) is bound as the GCM **additional authenticated data**, so no header segment can be swapped without failing the tag.
### Context binding (the AAD / HKDF `info`)
The HKDF `info` is a length-prefixed, canonical concatenation of a domain-separation label and the full context:
```
info = len‖"KnoxCall-kc1-ECIES-P256-AES256GCM"
‖ len‖tenant_id ‖ len‖app_key_id ‖ key_version(uint32 BE)
‖ len‖datatype ‖ len‖purpose
```
`purpose` is the optional **data-role** (`pci`, `eu`, …). Because the full context is folded into key derivation, a ciphertext is cryptographically pinned to the tenant, key, version, datatype, and data-role it was created under — decrypting under any different context simply fails the GCM tag.
**How this differs from raw-ECDH designs.** Some implementations feed the raw ECDH shared secret straight into AES with no KDF. KnoxCall **always** runs HKDF-SHA256 and binds the context into the `info` parameter. This gives domain separation, defends against cross-context reuse, and means a stolen ciphertext cannot be replayed under a different tenant, key, or role.
## Key hierarchy & custody
```
platform master key (env MASTER_KEY_B64, or unsealed from AWS/GCP/Azure KMS)
│ wraps
▼
tenant master key (per tenant; or wrapped by YOUR KMS under BYOK)
│ wraps
▼
app ecdh-p256 private key (PKCS#8 DER, stored only as a wrapped KCT1 envelope)
│ decrypts
▼
kc: ciphertext
```
* The app private key is **never stored in the clear** — only as a `KCT1` envelope wrapped under the tenant master key. The public half is cached for encryption.
* Under [BYOK](/essentials/tenant-kms/overview), the tenant master key is wrapped by **your** cloud KMS, so KnoxCall cannot unwrap it without a call your KMS policy authorises — and you can revoke that at any time.
* **Cryptographic erasure:** destroy a key version and every `kc:` value produced under it becomes permanently unreadable.
## Versioning & rotation
Every `kc:` value names its `key_version` in the `key_ref`. Rotating an `ecdh-p256` key mints a new version: new values encrypt under the new version while existing values still decrypt under theirs. No bulk re-encryption is required, and a long-running re-encrypt can pin a target version to stay consistent across a concurrent rotation.
## Parameter summary
| Parameter | Value |
| - | - |
| Curve | NIST P-256 (`prime256v1`), ephemeral per value |
| KDF | HKDF-SHA256, 32-byte output, salt = ephemeral public key, context-bound `info` |
| AEAD | AES-256-GCM, 12-byte IV, 16-byte tag |
| Header integrity | `kc:` header bound as GCM AAD |
| Key wrapping | KCT1 envelope (AES-256-GCM) under the per-tenant master key |
| Master key custody | env, or AWS KMS / GCP KMS / Azure Key Vault unseal; per-tenant BYOK |
## Migrating from Evervault `ev:`
KnoxCall cannot decrypt an Evervault `ev:` ciphertext (only your Evervault app holds that key). Migration is a one-way re-encryption you run with `scripts/migrate-ev-to-kc.ts`: decrypt each `ev:` value via your Evervault app, then re-encrypt via `POST /v1/encrypt` to get a `kc:` value.
# Portable Encryption overview
Source: https://docs.knoxcall.com/essentials/encryption/overview
Encrypt any JSON, get back a self-describing kc: ciphertext you can store anywhere. Public-key encryption so plaintext can be sealed before it ever reaches KnoxCall, plus single-use client-side reveal tokens.
# Portable Encryption overview
KnoxCall's **Portable Encryption** turns any value into a single, self-describing **`kc:` ciphertext string** you can drop into a database column, a log line, or a message queue with no schema change — and which carries everything needed to decrypt it *except* the private key.
It is public-key encryption: you encrypt against a tenant **public key**, and only KnoxCall (holding the matching private key, wrapped under your [tenant master key](/essentials/key-management/tenant-master-key)) can decrypt. That means plaintext can be sealed **before it ever reaches KnoxCall** — including in a browser.
Use it for:
* **"Encrypt anything, store it anywhere"** — PII, PHI, PAN, API responses, free-form blobs. The ciphertext is portable across systems.
* **Structure-preserving encryption** — send a whole JSON object; get the same shape back with every scalar leaf replaced by a `kc:` string.
* **Frontend scope reduction** — seal card/PII data in the page so it never touches your servers (paired with the browser SDK).
* **Crypto-agility** — every `kc:` value carries its scheme version, so the format can evolve without breaking stored data.
## Quick example
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# Encrypt — structure-preserving. Scalar leaves become kc: strings.
curl -X POST https://api.knoxcall.com/v1/encrypt \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "data": { "card": "4242424242424242", "amount": 1299 } }'
# → { "data": { "card": "kc:1:s:...:$", "amount": "kc:1:n:...:$" }, "key": "kc-default", "key_version": 1 }
# Decrypt — non-kc values pass through untouched.
curl -X POST https://api.knoxcall.com/v1/decrypt \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "data": { "card": "kc:1:s:...:$", "amount": "kc:1:n:...:$" } }'
# → { "data": { "card": "4242424242424242", "amount": 1299 } }
# Inspect — metadata about a ciphertext WITHOUT decrypting it.
curl -X POST https://api.knoxcall.com/v1/inspect \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "value": "kc:1:s:...:$" }'
# → { "encrypted": true, "scheme": "kc", "version": 1, "datatype": "s", "key_ref": {...}, "fingerprint": "..." }
```
If you don't name a `key`, KnoxCall auto-provisions a default `ecdh-p256` key on first use — the happy path is zero-config. To pin a key, pass `"key": "my-key"` (create it with `key_type: "ecdh-p256"` via [Crypto Keys](/essentials/crypto-keys/crypto-keys-overview)).
## Data roles
Pass an optional `role` on encrypt to bind a **data-role** into the ciphertext (e.g. `pci`, `eu`). The role is folded into the key derivation, so the value can only be decrypted by presenting the **same role** — the policy travels *with the data*, not just at the gateway.
```bash theme={"dark"}
curl ... -d '{ "data": "4242...", "role": "pci" }' # encrypt under role "pci"
curl ... -d '{ "data": "kc:...:$", "role": "pci" }' # must pass role "pci" to decrypt
```
## Client-side reveal without an API key
Browsers and agents should never hold a KnoxCall credential. To let a frontend reveal **one** value, your backend mints a **single-use, payload-pinned capability token** and hands it over:
```bash theme={"dark"}
# Backend (holds the KnoxCall credential): mint a token bound to exactly one ciphertext.
curl -X POST https://api.knoxcall.com/v1/client-tokens \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "action": "decrypt", "data": "kc:1:s:...:$", "ttl_seconds": 300 }'
# → { "token": "kct_...", "expires_at": "...", "action": "decrypt" }
# Browser (no KnoxCall credential): exchange the token ONCE for the plaintext.
curl -X POST https://api.knoxcall.com/v1/client/decrypt \
-H "Authorization: Bearer kct_..." -H "Content-Type: application/json" \
-d '{ "data": "kc:1:s:...:$" }'
# → { "data": "4242424242424242" }
```
The token is consumed on first use, expires within minutes, and only matches the exact ciphertext it was minted for. A leak reveals at most that one value, once. The same flow works for vault tokens with `action: "detokenize"` → `POST /v1/client/detokenize`.
## Dual-custody vaults
A [vault](/essentials/vaults/overview) created with `custody_mode: "dual"` issues `kc:` ciphertext as its token and **stores no value at all** — you keep the ciphertext, KnoxCall keeps only the key. A breach of KnoxCall alone leaks nothing, and detokenize is a pure decrypt (no database lookup).
## Learn more
* [Ciphertext format & cryptographic spec](/essentials/encryption/ciphertext-spec) — the exact wire format and algorithms.
* [Encryption threat model](/security/threat-model) — what the design defends against, and the trust boundaries.
# Collection Environment Defaults
Source: https://docs.knoxcall.com/essentials/environments/collection-environment-defaults
Define expected environments, base URLs, and default clients at the collection level. New routes auto-provision, existing routes stay in sync, and drift is visible at a glance.
# Collection Environment Defaults
When all the routes in a collection share the same shape — the same environments, the same target host pattern, the same set of clients — it's tedious (and error-prone) to configure each route individually.
**Collection environment defaults** let you declare what a collection *expects* once, and every route in the collection automatically picks it up.
## What You Can Define
For each collection, you configure:
| Setting | Scope | Description |
| - | - | - |
| **Expected environments** | Collection | The list of environments routes in this collection should have (e.g. `production`, `development`) |
| **Base URL** | Per environment | The default target host used when prefilling new routes |
| **Default clients** | Per environment | Clients automatically attached to routes in that environment |
| **Header injections** | Per environment | *(Reserved for future use)* |
| **Body injections** | Per environment | *(Reserved for future use)* |
Expected environments is a **list**, not a single value. A Shopify collection might expect both `production` and `development`, so every route in it is guaranteed to have both configured.
***
## Why Use Collection Defaults?
### Without Defaults
Every time you add a route to a collection, you have to remember to:
1. Create a `production` environment config
2. Create a `development` environment config
3. Paste the same base URL everywhere
4. Attach the same four or five clients on both environments
5. Hope nobody forgets a step three weeks from now
### With Defaults
* **Declare once** — "This collection has production and development, base URLs are X and Y, and these clients are always attached."
* **Auto-provision on join** — When a route joins the collection, missing environments are created silently from the defaults.
* **Prefill on create** — The route creation form fills in the base URL from the collection.
* **Drift detection** — Routes that drift from the defaults are flagged on both the collection page (sync stats) and the route page (clients compare row).
* **One-click reset** — Bring a route back to the canonical client list with a single click.
***
## Setting Up Defaults
### 1. Open the Collection's Environments Tab
Navigate to **Collections → *your collection* → Environments** tab.
### 2. Declare Expected Environments
At the top of the tab, click any environment chip to add or remove it from the expected list.
```text theme={"dark"}
Expected environments: [✓ production] [✓ development] [ staging ]
```
Removing an environment from the expected list only stops auto-provisioning going forward. Existing route environment configs are **preserved**, not deleted. If any routes already use that environment, you'll be warned before the change is applied.
### 3. Configure Each Environment
Pick an environment from the dropdown and fill in:
* **Base URL** — e.g. `https://api.shopify.com` for production, `https://sandbox.shopify.com` for development
* **Default clients** — Add the clients that should automatically be attached to every route in this collection+environment
### 4. Save
Unsaved changes are shown in a sticky save bar at the bottom of the tab. Click **Save** to persist.
Saving only updates the collection's defaults — it does **not** automatically apply them to existing routes. Use **Apply to routes** to push changes out.
***
## Auto-Provision on Join
When a route joins a collection (either at creation time or by changing its collection later), KnoxCall silently creates any missing environment configs from the collection defaults.
```text theme={"dark"}
Route joins "Shopify APIs" collection
└─ Collection expects: [production, development]
└─ Route currently has: [production]
Result: development is auto-created with:
• Base URL from collection
• Default clients from collection
• inject_headers / inject_body from collection (when configured)
```
This happens silently — no modal, no confirmation. Routes always have every environment the collection expects.
Auto-provisioning respects tenant boundaries: environments that don't exist for your tenant are skipped.
***
## Base URL Prefill on Route Creation
When creating a new route, selecting a collection triggers a preview panel showing the environments that will be provisioned.
At the same time, the target URL input is prefilled with the first expected environment's base URL. A small hint appears below the input:
> *Prefilled from collection (production) — click to edit*
As soon as you start typing, the prefill is locked out — KnoxCall will never overwrite a value you've explicitly edited.
***
## Keeping Routes in Sync
Defaults change over time. When they do, KnoxCall helps you bring existing routes back in line.
### The Sync Stats Row
On the collection's Environments tab, below the default-clients editor, a sync row shows:
```text theme={"dark"}
12 routes • 8 synced • 3 out of sync • 1 missing env [ Apply to routes ]
```
* **Synced** — The route's client list matches the defaults exactly.
* **Out of sync** — The route is missing some defaults, has extra clients, or both.
* **Missing env** — The route has no config for this environment yet.
### Apply to Routes Modal
Clicking **Apply to routes** opens a modal that shows every route in the collection with its individual diff:
* **+ ClientName (will be added)** — Missing defaults that will be attached.
* **− ClientName (will be removed)** — Extras that will be detached *(replace mode only)*.
* **(keeping: ClientName)** — Extras that will be preserved *(merge mode)*.
You choose the **apply mode**:
Add missing defaults, preserve any extra clients already on the route.
Safe for routes that legitimately have extra clients.
Hard sync: add defaults, remove anything not in the defaults.
Use when the defaults must be authoritative.
Routes that are out-of-sync or missing-env are **preselected** by default, so one click catches you up on everything.
***
## The Route-Level Compare Row
Open any route in a collection that has defaults. On the **Clients** tab, the client compare widget adds a **`{collection} default`** row (for example, `Shopify APIs default`) showing the canonical client list for the current environment. When the route's clients differ from — or are missing — those defaults, the row exposes a single **Apply** action.
### Out of Sync
The compare row shows the collection's desired client list alongside the route's current one. The Apply action is titled:
```text theme={"dark"}
Reset production to Shopify APIs defaults
```
Triggering it performs a **hard replace** — extra clients are removed, defaults are added exactly. This is the intentional, explicit way to snap a single route back to canonical.
### Missing Env
When the route has no config for an environment the collection expects, the compare row's Apply action is titled instead:
```text theme={"dark"}
Provision development from Shopify APIs defaults
```
Triggering it creates the environment config for this route using the collection's base URL and default clients.
The compare row is part of the clients comparison widget — there's no separate banner to dismiss. It simply reflects the current diff and disappears once the route is in sync.
***
## Merge vs Replace: When to Use Each
| Situation | Use | Why |
| - | - | - |
| Routine sync after adding a default client | **Merge (bulk)** | Safe — won't clobber route-specific extras |
| Onboarding a fleet of existing routes into a collection | **Merge (bulk)** | Adds missing defaults without surprises |
| A single route has drifted and you want it back to canonical | **Reset (compare row)** | Explicit per-route hard sync |
| The collection is the single source of truth and extras are forbidden | **Replace (bulk)** | Enforces exact defaults on every route |
***
## Real-World Example
**Scenario:** An e-commerce team has a "Shopify APIs" collection with 8 routes. They always run routes in both `production` and `development`, and every route needs the `monitoring-probe`, `office-vpn`, and `ci-runner` clients.
### Step 1: Configure the collection once
```text theme={"dark"}
Collection: Shopify APIs
Expected environments: [production, development]
Production:
Base URL: https://api.shopify.com
Default clients: monitoring-probe, office-vpn, ci-runner
Development:
Base URL: https://sandbox.shopify.com
Default clients: monitoring-probe, office-vpn, ci-runner, dev-laptop
```
### Step 2: Add a new route
The developer creates `shopify-orders` and picks the Shopify APIs collection. KnoxCall:
1. Shows a preview: *"will pre-configure 2 environments"*
2. Prefills the target URL with `https://api.shopify.com`
3. On save, auto-provisions the `development` environment with its base URL and 4 default clients
No manual client-attachment, no copy-paste of URLs.
### Step 3: Add a new default client
Three months later, the team adds `newrelic-probe` to the collection defaults and clicks **Apply to routes** with **Merge** mode. All 8 routes receive `newrelic-probe` on both environments — without disturbing any route-specific extras.
### Step 4: A stray route
A single route `shopify-legacy-sync` was modified by hand and has `legacy-jenkins` attached. Opening its clients tab shows the compare row out of sync with the `Shopify APIs default` — `newrelic-probe` will be added.
The developer triggers the **Reset production to Shopify APIs defaults** action. `legacy-jenkins` is removed, `newrelic-probe` is added, and the route matches canonical.
***
## How It Differs from Collection Variables
KnoxCall collections have two related — but separate — systems:
| Feature | What It Does |
| - | - |
| **Collection variables** | Resolves `{{var:name}}` template placeholders in route configs at proxy time |
| **Collection environment defaults** *(this page)* | Stamps values onto the route itself when it joins the collection or when you click Apply — the route then owns the values |
In other words: defaults are a create-time / apply-time stamp — routes own their values, but can be brought back in sync on demand with Apply/Reset. There is no proxy-time inheritance lookup; every route's runtime config lives on its own per-environment rows.
***
## Audit Trail
Every defaults operation is logged to **Audit Logs**:
| Action | When |
| - | - |
| `collection_defaults.apply` | Bulk apply with merge or replace |
| `route_environment.reset_to_collection_defaults` | Per-route reset via the compare row Apply action |
| `route_environment.provisioned_from_collection` | Per-route provision via the compare row Apply action |
| `route_environment.auto_provisioned` | Automatic provision when a route joined a collection |
Each entry records the collection, environment, route(s) affected, and the mode used.
***
## Best Practices
### ✅ Do
* **Declare expected environments early.** Set them before adding routes so auto-provisioning catches everything.
* **Prefer Merge over Replace for bulk apply.** Merge is the safe default; Replace should be a deliberate choice.
* **Use Reset (per-route compare row) for drift fixes.** It's unambiguous and audit-logged per route.
* **Review the Apply modal's diff before confirming.** The preselection helps, but eyeballing it catches surprises.
### ❌ Don't
* **Don't use Replace to "clean up" routes you don't fully understand.** Extras may be there for a reason.
* **Don't remove an expected environment to hide drift.** The warning flow lets you remove it, but the underlying route configs stay — the drift just becomes invisible.
* **Don't expect defaults to update routes retroactively.** Defaults stamp values at create/apply time; use Apply or Reset to bring routes back in sync with the collection.
***
## Quick Reference
| Action | Where | Behavior |
| - | - | - |
| Declare expected envs | Collection → Environments tab → chip row | Auto-provisions on route join |
| Set base URL + default clients | Collection → Environments tab → per-env editor | Saved to the collection only |
| Prefill route target URL | Route create page (after picking collection) | Locked out once you type |
| Bulk apply to routes | Collection → Environments tab → Apply to routes | Merge or Replace, multi-select |
| Single-route reset | Route → Clients tab → compare row Apply | Hard replace |
| Single-route provision | Route → Clients tab → compare row Apply (missing env) | Creates env from defaults |
***
## Next Steps
Understand the underlying environment system
Learn how clients attach to route environments
Advanced patterns across environments
Review every defaults operation
***
**Key Takeaway**: Declare a collection's expected environments, base URLs, and default clients once. New routes auto-provision, existing routes stay in sync via Apply or per-route Reset, and drift never hides for long.
# Environment Basics
Source: https://docs.knoxcall.com/essentials/environments/environment-basics
Understand environments in KnoxCall. Learn how to manage dev, staging, and production configurations with a single route.
# Environment Basics
**Environments** in KnoxCall allow you to manage different configurations (dev, staging, production) for the same route without duplicating everything.
## What is an Environment?
An **environment** is a configuration context that determines which settings to use for a route. Think of it as a profile or mode.
### Real-World Analogy
Imagine a light switch with multiple modes:
* **"Day" mode** → Bright lights, full power
* **"Night" mode** → Dim lights, low power
* **"Movie" mode** → Colored lights, ambient
Same switch, different settings based on the mode.
**Environments work the same way:**
* **Production** → Real API keys, production database
* **Staging** → Test API keys, staging database
* **Development** → Mock data, local server
***
## Why Use Environments?
### The Problem Without Environments
Without environments, you'd need **separate routes** for each stage:
```text theme={"dark"}
❌ user-api-dev → https://localhost:3000
❌ user-api-staging → https://staging.api.com
❌ user-api-prod → https://api.production.com
```
**Issues:**
* Duplicate routes to maintain
* Easy to misconfigure
* Hard to keep in sync
* Route names get messy
### The Solution With Environments
With environments, you have **one route** with different configs:
```text theme={"dark"}
✅ user-api (base route)
├── production → https://api.production.com
├── staging → https://staging.api.com
└── development → https://localhost:3000
```
**Benefits:**
* Single route to maintain
* Consistent naming
* Easy to switch between environments
* Clear separation of configs
***
## How Environments Work
### The Base + Override System
KnoxCall uses a **base + override** system:
1. **Base environment** - Default configuration
2. **Override environments** - Customize specific settings
**Example:**
```text theme={"dark"}
Route: stripe-payments
Base (production):
Target URL: https://api.stripe.com
Header: Authorization: Bearer {{secret:stripe_prod_key}}
Clients: [production-server]
Override (staging):
Target URL: INHERIT from base ✓
Header: Authorization: Bearer {{secret:stripe_staging_key}} ← Override
Clients: [staging-server, dev-laptop] ← Override
Override (development):
Target URL: https://stripe-sandbox.com ← Override
Header: INHERIT from base ✓
Clients: [dev-laptop] ← Override
```
**How it works:**
* If override = `null` or empty → Use base value
* If override = specific value → Use override value
* Changes to base automatically affect overrides (unless overridden)
* Inheritance is single-level: each override falls back to the **base** (production) config, never to another override. There is no override-to-override chaining.
***
## Default Environments
KnoxCall creates one default environment for every tenant:
### Production (Base Environment)
* **Name:** `production`
* **Type:** Base
* **Purpose:** Default configuration
* **Usage:** If no environment specified, this is used
When you create a route, you're actually configuring the **production environment**.
***
## Common Environment Setup
### Typical 3-Environment Setup
Most teams use three environments:
#### 1. Development
* **Purpose:** Local development and testing
* **Target:** `http://localhost:3000` or sandbox APIs
* **Secrets:** Test/fake API keys
* **Clients:** Developer laptops (dynamic IPs)
* **Example:**
```
Name: development
Target: https://sandbox.stripe.com
Secret: stripe_test_key
Clients: dev-laptop-1, dev-laptop-2
```
#### 2. Staging
* **Purpose:** Pre-production testing
* **Target:** Staging servers
* **Secrets:** Staging API keys (real-ish data)
* **Clients:** Staging servers + QA team
* **Example:**
```
Name: staging
Target: https://staging-api.acme.com
Secret: stripe_staging_key
Clients: staging-server, qa-team-ips
```
#### 3. Production
* **Purpose:** Live customer traffic
* **Target:** Production servers
* **Secrets:** Production API keys (real data!)
* **Clients:** Production servers only
* **Example:**
```
Name: production
Target: https://api.acme.com
Secret: stripe_prod_key
Clients: prod-server-1, prod-server-2
```
***
## Using Environments in Requests
### Specify Environment with Header
To use a specific environment, pass the `environment` option in the [SDK](/sdks/overview):
```typescript theme={"dark"}
// Call production (default)
const resp = await client.call("user-api", { path: "/api/users" });
// Call staging explicitly
await client.call("user-api", { path: "/api/users", environment: "staging" });
// Call development explicitly
await client.call("user-api", { path: "/api/users", environment: "development" });
```
Or, in raw curl, add the `x-knoxcall-environment` header — `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)):
```bash theme={"dark"}
# Call production (default)
curl https://acme.knoxcall.com/api/users \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api"
# Call staging explicitly
curl https://acme.knoxcall.com/api/users \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api" \
-H "x-knoxcall-environment: staging"
# Call development explicitly
curl https://acme.knoxcall.com/api/users \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api" \
-H "x-knoxcall-environment: development"
```
### No Header = Base Environment
If you don't specify `x-knoxcall-environment`, KnoxCall uses the **base environment** (usually `production`).
***
## What Can Be Overridden?
You can override almost everything per environment:
### ✅ Can Override
| Setting | Example |
| - | - |
| **Target URL** | `https://api.production.com` vs `https://localhost:3000` |
| **Headers** | Different API keys, auth tokens |
| **Body** | Different payload structures |
| **Secrets** | Production vs test secrets |
| **Clients** | Production servers vs dev laptops |
| **Rate limits** | Higher limits in prod, lower in dev |
| **Method configs** | Different per HTTP method |
### ❌ Cannot Override
| Setting | Reason |
| - | - |
| Route name | Would create confusion |
| Enabled/disabled | Applies to all environments |
| HTTP methods allowed | Security concern |
***
## Real-World Examples
### Example 1: Stripe API Route
**Scenario:** Process payments via Stripe in all environments.
**Setup:**
```text theme={"dark"}
Route: stripe-payments
Methods: POST
Production (base):
Target: https://api.stripe.com
Header: Authorization: Bearer {{secret:stripe_prod_key}}
Clients: [prod-payment-server]
Staging:
Target: INHERIT ✓
Header: Authorization: Bearer {{secret:stripe_test_key}} ← Test key
Clients: [staging-server, qa-laptop]
Development:
Target: INHERIT ✓
Header: Authorization: Bearer {{secret:stripe_test_key}} ← Same test key
Clients: [dev-laptop-1, dev-laptop-2, dev-laptop-3]
```
**Result:**
* Production charges real cards with prod key
* Staging/dev use test keys (no real charges)
* Different client authorization per environment
### Example 2: Database API
**Scenario:** Query user database across environments.
**Setup:**
```text theme={"dark"}
Route: user-database
Methods: GET, POST
Production (base):
Target: https://prod-db.acme.com
Header: X-API-Key: {{secret:prod_db_key}}
Clients: [prod-api-server]
Staging:
Target: https://staging-db.acme.com ← Different database!
Header: X-API-Key: {{secret:staging_db_key}} ← Different key
Clients: [staging-api-server]
Development:
Target: http://localhost:5432 ← Local database
Header: X-API-Key: {{secret:dev_db_key}}
Clients: [ANY] ← No IP restriction for dev
```
**Result:**
* Each environment hits a completely different database
* Developers work with local data
* Staging has realistic test data
* Production has real customer data
### Example 3: Third-Party Webhooks
**Scenario:** Receive webhooks from Shopify.
**Setup:**
```text theme={"dark"}
Route: shopify-webhooks
Methods: POST
Production (base):
Target: https://api.acme.com/webhooks/shopify
Header: X-Shopify-Secret: {{secret:shopify_prod_secret}}
Clients: [shopify-prod-ips]
Staging:
Target: https://staging.acme.com/webhooks/shopify
Header: X-Shopify-Secret: {{secret:shopify_staging_secret}}
Clients: [shopify-staging-ips]
Development:
Target: https://ngrok-abc123.ngrok.io/webhooks/shopify ← Tunneled local
Header: INHERIT ✓
Clients: [shopify-test-ips]
```
**Result:**
* Production webhooks go to production backend
* Staging webhooks go to staging
* Dev webhooks tunnel to developer's localhost via ngrok
***
## NULL Values = Inherit
This is a key concept: **NULL or empty values inherit from the base.**
### Example
```text theme={"dark"}
Base:
Target URL: https://api.production.com
Header: { "Authorization": "Bearer {{secret:prod_key}}" }
Override:
Target URL: null ← Use base (https://api.production.com)
Header: { "Authorization": "Bearer {{secret:staging_key}}" } ← Override
```
**Result:**
* Target URL: `https://api.production.com` (inherited)
* Header: `Bearer staging_key` (overridden)
***
## Best Practices
### ✅ Do
**1. Use descriptive environment names**
```text theme={"dark"}
✅ production, staging, development
✅ prod, stage, dev
✅ live, test, local
```
**2. Separate secrets per environment**
```text theme={"dark"}
✅ stripe_prod_key (production)
✅ stripe_test_key (staging/dev)
```
**3. Different client authorization**
```text theme={"dark"}
Production: Only production servers
Staging: Staging servers + QA team
Development: All developer machines
```
**4. Test in staging first**
```text theme={"dark"}
1. Make changes in staging
2. Test thoroughly
3. Deploy to production
```
**5. Document environment purpose**
```text theme={"dark"}
Development: Local testing, fake data
Staging: Pre-prod testing, realistic data
Production: Live customer traffic, real data
```
### ❌ Don't
**1. Don't use production secrets in dev**
```text theme={"dark"}
❌ development: stripe_prod_key (dangerous!)
✅ development: stripe_test_key
```
**2. Don't allow dev IPs in production**
```text theme={"dark"}
❌ Production clients: [dev-laptop-1, dev-laptop-2]
✅ Production clients: [prod-server-1, prod-server-2]
```
**3. Don't mix environments**
```text theme={"dark"}
❌ Staging using production database
✅ Staging using staging database
```
**4. Don't skip staging**
```text theme={"dark"}
❌ Dev → Production (risky!)
✅ Dev → Staging → Production
```
**5. Don't use generic names**
```text theme={"dark"}
❌ env1, env2, test
✅ staging, development
```
***
## When to Create New Environments
### Create New Environments When:
1. **Different target systems**
* Production API vs staging API vs local dev
2. **Different credentials**
* Production keys vs test keys
3. **Different authorization**
* Production servers vs developer laptops
4. **Different teams**
* QA environment separate from dev
5. **Different regions**
* US environment vs EU environment
### Don't Create Environments For:
1. **Different features** - Use different routes instead
2. **Different customers** - Use route parameters or headers
3. **Different versions** - Use API versioning in URLs
4. **Temporary testing** - Use staging environment
***
## Environment Workflows
### Development Workflow
```text theme={"dark"}
1. Developer creates route in UI
↓
2. Tests in "development" environment
curl ... -H "x-knoxcall-environment: development"
↓
3. Works locally? → Push code to staging server
↓
4. Test in "staging" environment
curl ... -H "x-knoxcall-environment: staging"
↓
5. Staging works? → Deploy to production
↓
6. Test in "production" environment (carefully!)
curl ... -H "x-knoxcall-environment: production"
```
### Environment Promotion
```text theme={"dark"}
Development:
✓ Local testing
✓ Rapid iteration
↓
Staging:
✓ Integration testing
✓ QA approval
↓
Production:
✓ Customer traffic
✓ Real transactions
```
***
## Troubleshooting
### Environment Not Found
**Error:**
```text theme={"dark"}
Environment 'staging' not found for this route. Available environments can be configured in the admin panel.
```
**Solution:**
1. Go to **Resources** → **Environments**
2. Check if "staging" exists
3. Create it if missing
4. Verify spelling (case-sensitive)
### Using Wrong Environment
**Symptom:** Request goes to wrong backend.
**Solution:**
1. Check `x-knoxcall-environment` header value
2. Verify environment name spelling
3. Check which environment your route is configured for
### Override Not Working
**Symptom:** Still using base configuration despite override.
**Cause:** Override value is `null` (inheritance).
**Solution:**
1. Edit route → **Environment** tab
2. Set specific value (not null)
3. Save changes
***
## Quick Reference
| Concept | Explanation |
| - | - |
| **Environment** | Configuration context (dev, staging, prod) |
| **Base Environment** | Default configuration (usually production) |
| **Override** | Environment-specific customization |
| **Inheritance** | NULL/empty values use base configuration |
| **Switching** | Use `x-knoxcall-environment` header |
***
## Next Steps
Advanced environment patterns
Per-environment rate limits
Create your first environment
Environment-specific secrets
***
**Key Takeaway**: Environments let you manage dev, staging, and production with **one route** instead of three. Override only what's different, inherit the rest!
# Environment Deployment Workflows
Source: https://docs.knoxcall.com/essentials/environments/environment-workflows
Learn proven workflows for deploying changes from dev to staging to production safely using KnoxCall environments.
# Environment Deployment Workflows
Learn proven workflows for safely deploying changes from development to production using KnoxCall environments.
## Standard Three-Tier Workflow
```text theme={"dark"}
Development → Staging → Production
```
This is the most common deployment pattern used by successful teams.
### Workflow Overview
| Environment | Purpose | Stability | Users |
| - | - | - | - |
| **Development** | Feature development, experimentation | Unstable | Developers |
| **Staging** | Pre-production testing, QA | Stable | QA team, stakeholders |
| **Production** | Live customer-facing system | Very stable | End users |
## How KnoxCall Makes This Easy
With KnoxCall, you don't need to change code or redeploy your client applications to switch environments.
**Traditional approach:**
```javascript theme={"dark"}
// Code changes needed
const API_URL = process.env.NODE_ENV === 'prod'
? 'https://api.prod.com'
: 'https://api.dev.com';
```
**With KnoxCall:**
```javascript theme={"dark"}
// No code changes - just an option
const resp = await client.call("my-api", {
path: "/api/users",
environment: "staging" // Change only this
});
```
Same code, different backend via one option (or the `x-knoxcall-environment` header on raw HTTP)!
## Deployment Process
### Step 1: Develop in Development
**What:** Make code changes and test locally
**KnoxCall setup:**
* Environment: `development`
* Target URL: `http://localhost:3000`
* Secrets: Dev API keys
* Clients: Your laptop IP
* Rate limits: Unlimited
**Test your changes** (`$TOKEN` is a minted OAuth access token — see [Authentication](/api-reference/authentication)):
```bash theme={"dark"}
curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api" \
-H "x-knoxcall-environment: development" \
-d '{"name": "Test User"}'
```
Routes to: `http://localhost:3000/api/users`
**Iterate quickly:**
* Make changes to local backend
* Test immediately
* No deployment needed
### Step 2: Promote to Staging
**What:** Deploy backend to staging server, run full QA
**KnoxCall setup:**
* Environment: `staging`
* Target URL: `https://staging-api.yourcompany.com`
* Secrets: Staging API keys
* Clients: Staging server IPs, QA team IPs
* Rate limits: Moderate (1000/min)
**Deploy backend:**
```bash theme={"dark"}
# Deploy your backend to staging server
git push staging main
# Or: deploy via CI/CD
```
**Test with staging environment:**
```bash theme={"dark"}
curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api" \
-H "x-knoxcall-environment: staging" \
-d '{"name": "Test User"}'
```
Routes to: `https://staging-api.yourcompany.com/api/users`
**Staging checklist:**
* [ ] All unit tests pass
* [ ] Integration tests pass
* [ ] Manual QA completed
* [ ] Performance acceptable
* [ ] Security scan passed
* [ ] No critical bugs
* [ ] Stakeholder approval
### Step 3: Deploy to Production
**What:** Deploy to live environment after thorough testing
**KnoxCall setup:**
* Environment: `production` (base environment)
* Target URL: `https://api.yourcompany.com`
* Secrets: Production API keys
* Clients: Production server IPs only
* Rate limits: Strict (100/min)
**Deploy backend:**
```bash theme={"dark"}
# Deploy to production
git push production main
# Or: deploy via CI/CD with approval gate
```
**Production requests** (no environment header needed):
```bash theme={"dark"}
curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: user-api" \
-d '{"name": "Real User"}'
```
Routes to: `https://api.yourcompany.com/api/users` (default/base environment)
**Post-deployment monitoring:**
* [ ] Monitor error rates in KnoxCall dashboard
* [ ] Check response times
* [ ] Watch for spike in 5xx errors
* [ ] Verify no increase in failed requests
* [ ] Monitor user reports/support tickets
## Environment-Specific Configuration
### Different Rate Limits Per Environment
**Development:** Relaxed for testing
```text theme={"dark"}
Rate limit: 10,000 requests/minute (or unlimited)
Purpose: Allow rapid iteration
```
**Staging:** Moderate limits
```text theme={"dark"}
Rate limit: 1,000 requests/minute
Purpose: Test rate limiting behavior, allow load testing
```
**Production:** Strict protection
```text theme={"dark"}
Rate limit: 100 requests/minute per client
Purpose: Protect backend from abuse
```
**Configure in KnoxCall:**
1. Edit route
2. Go to **Environment** tab
3. Set different `rate_limit_requests` and `rate_limit_window_sec` per environment
### Different Secrets Per Environment
Use environment-specific secrets for security:
**Development route override:**
```json theme={"dark"}
{
"inject_headers": {
"Authorization": "Bearer {{secret:stripe_dev_key}}"
}
}
```
**Staging route override:**
```json theme={"dark"}
{
"inject_headers": {
"Authorization": "Bearer {{secret:stripe_staging_key}}"
}
}
```
**Production (base route):**
```json theme={"dark"}
{
"inject_headers": {
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
}
```
Now each environment uses its own Stripe key automatically!
### Different Monitoring Thresholds
**Development:** No alerts (expect errors during development)
**Staging:** Warning-level alerts
* Alert if error rate > 20%
* Alert if P95 latency > 5 seconds
* Send to Slack `#staging-alerts` channel
**Production:** Critical alerts
* Alert if error rate > 5%
* Alert if P95 latency > 1 second
* Send to PagerDuty for on-call rotation
* Send to Slack `#production-alerts` channel
**Configure in KnoxCall:**
1. Navigate to **Alerts**
2. Create alert rules with environment filters
3. Set different thresholds per environment
4. Configure notification channels
## Rollback Strategy
If production deployment fails, you can roll back instantly without code changes.
### Quick Rollback via Environment Override
**Scenario:** New backend version (v2) has critical bug
**Option 1: Point production to old backend**
1. Edit route → **Environment** tab
2. Add override for "production" environment
3. Set target URL to old backend:
```
https://api-v1.yourcompany.com (old version)
```
4. Save
Traffic instantly goes to old backend. No code changes needed!
**Option 2: Switch production to use staging**
Temporarily point production traffic to staging backend:
1. Edit route → **Environment** tab
2. Override production target URL:
```
https://staging-api.yourcompany.com
```
3. Save
This buys time to fix the bug while serving from last known good version.
### Full Rollback Workflow
```text theme={"dark"}
1. Identify issue in production
↓
2. Immediately rollback route to old backend URL
↓
3. Investigate issue in staging
↓
4. Fix bug and test in development
↓
5. Validate fix in staging
↓
6. Redeploy to production
↓
7. Update route back to new backend URL
```
## Advanced Deployment Patterns
### Feature Branch Environments
For large features, create temporary environments:
**Example: New payment system**
1. Create environment: `feature-payment-v2`
2. Configure route override:
* Target URL: `https://payment-v2-api.yourcompany.com`
* Secrets: `payment_v2_api_key`
3. Test feature branch in isolation
4. After merge: Delete `feature-payment-v2` environment
**Benefits:**
* Test big changes safely
* Don't interfere with staging
* Easy cleanup after merge
### Canary Deployments
Gradually roll out to production:
**Phase 1: Internal testing (5% of traffic)**
```text theme={"dark"}
Environment: production-canary
Target: https://api-v2.yourcompany.com (new version)
Clients: Internal testing IPs only
```
**Phase 2: Beta users (20% of traffic)**
```text theme={"dark"}
Environment: production-beta
Target: https://api-v2.yourcompany.com
Clients: Beta tester IPs
```
**Phase 3: Full rollout (100% of traffic)**
```text theme={"dark"}
Environment: production (base)
Target: https://api-v2.yourcompany.com
Clients: All production IPs
```
### Blue-Green Deployments
Run two identical production environments:
**Blue (current production):**
```text theme={"dark"}
Environment: production
Target: https://api-blue.yourcompany.com (v1.5)
```
**Green (new version):**
```text theme={"dark"}
Environment: production-green
Target: https://api-green.yourcompany.com (v1.6)
```
**Switch traffic:** Update base route to point to green
**Instant rollback:** Switch back to blue if issues
### Regional Environments
For global applications, route by region:
```text theme={"dark"}
production-us-east
production-us-west
production-eu-west
production-asia-pacific
```
Client application determines region and sets environment header accordingly.
**Benefits:**
* Data sovereignty compliance
* Reduced latency (users hit closest region)
* Region-specific deployments
* Isolated failures (one region down doesn't affect others)
## Monitoring Across Environments
### Compare Metrics Dashboard
Create a dashboard showing all environments side-by-side:
| Metric | Development | Staging | Production |
| - | - | - | - |
| **Request volume** | 50/min | 500/min | 5,000/min |
| **Error rate** | 15% (expected) | 2% | 0.5% |
| **P95 latency** | 2s | 500ms | 200ms |
| **Success rate** | 85% | 98% | 99.5% |
**In KnoxCall:**
1. Navigate to **Analytics**
2. Group by "Environment"
3. Compare metrics across environments
4. Spot differences between staging and production
### Environment Health Dashboard
Monitor the overall health of each environment:
```text theme={"dark"}
✅ Development: Healthy (expected errors during dev)
⚠️ Staging: Warning - High error rate (needs investigation)
🔴 Production: Critical - Service degraded (page on-call)
```
## Troubleshooting Deployment Issues
### Changes Not Showing in Staging
**Symptoms:** Made changes, but staging still shows old behavior
**Possible causes:**
1. Backend not deployed yet
2. Wrong environment header in request
3. Caching (client-side or CDN)
4. Environment override not configured
**Debug steps:**
```bash theme={"dark"}
# 1. Verify backend deployment
ssh staging-server
ps aux | grep myapp
curl localhost:3000/health
# 2. Test with correct header
curl https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \
-H "x-knoxcall-environment: staging" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-api"
# 3. Check KnoxCall logs
# Go to Logs → Filter by environment: staging
# Verify request hit staging backend
# 4. Verify environment override
# Routes → Your Route → Environment tab → select Staging
# Check target URL is correct
```
### Different Behavior Between Environments
**Symptoms:** Works in staging, fails in production
**Possible causes:**
1. Different secrets (dev keys vs prod keys)
2. Different backend code versions
3. Different environment variables
4. Different database state
5. Different rate limits causing throttling
**Debug checklist:**
* [ ] Compare route configurations (base vs overrides)
* [ ] Verify secrets are environment-specific
* [ ] Check backend versions match
* [ ] Compare environment variables
* [ ] Review rate limit settings
* [ ] Check database schema versions
## Pre-Deployment Checklist
Before each production deployment, complete this checklist:
### Pre-Deployment
* [ ] All tests passing in staging
* [ ] Stakeholder approval received
* [ ] Rollback plan documented
* [ ] Team notified in Slack
* [ ] Monitoring dashboard ready
* [ ] On-call engineer identified
* [ ] Database migrations tested (if any)
* [ ] Feature flags configured (if applicable)
### During Deployment
* [ ] Deploy backend to production
* [ ] Verify health endpoint responds
* [ ] Test critical user flows manually
* [ ] Monitor error rates in real-time
* [ ] Check latency metrics
* [ ] Review first 100 production requests
### Post-Deployment
* [ ] Monitor for 30 minutes
* [ ] Review error logs (should be stable)
* [ ] Check analytics (traffic patterns normal)
* [ ] Verify zero increase in support tickets
* [ ] Update team status: "Deployment successful"
* [ ] Update documentation (if needed)
* [ ] Mark deployment as stable in tracking system
## Best Practices Summary
✅ **Do:**
* Always test in development first
* Require QA approval for staging → production
* Set production as default environment
* Use different API keys per environment
* Monitor each environment separately
* Document rollback procedures
* Keep staging close to production config
* Run load tests in staging
❌ **Don't:**
* Deploy directly to production without staging
* Use production API keys in development
* Skip the staging environment ("it's just a small change")
* Forget to monitor after deployment
* Make manual changes directly in production
* Test in production (use staging!)
## Next Steps
Now that you understand deployment workflows:
Set up environment-specific alerts
Manage secrets per environment
Configure limits per environment
Track configuration changes
## Related Concepts
* **Base Route**: Default production configuration
* **Environment Override**: Environment-specific settings (NULL = inherit)
* **Default Environment**: Used when no header specified
* **Per-environment clients**: IP whitelist per environment
* **Rollback**: Point route to previous backend version
***
* **Level**: intermediate
* **Time**: 15 minutes
`environments`, `deployment`, `workflows`, `devops`, `staging`
# Ephemeral Proxy overview
Source: https://docs.knoxcall.com/essentials/ephemeral-proxy/overview
Proxy any HTTPS request through KnoxCall without registering a Route first. Useful for ad-hoc API calls, agent runtimes, and one-shot integrations that don't justify a permanent Route.
# Ephemeral Proxy overview
KnoxCall's **Ephemeral Proxy** lets you proxy any HTTPS request through KnoxCall without registering a [Route](/essentials/routes/what-are-routes) first. Pass the target URL in the `X-Knox-Proxy-URL` header and send your payload as the request body; KnoxCall resolves any Vault token references, makes the request, and streams the response back verbatim.
Think of it as Routes' lower-friction cousin: same proxy core (secrets injection, egress controls, audit) without the upfront configuration step.
## Why use it
| Problem | Without ephemeral | With ephemeral |
| - | - | - |
| Agent runtime needs to call an arbitrary URL once | Pre-register every URL it might hit (impossible) | One-shot proxy with the same secrets + audit story |
| Internal script needs to call an API, but not enough volume to deserve a Route | Either register it (config noise) or call it directly (audit gap) | Use ephemeral; gets logged like any Route call |
| Testing a new integration before committing to it | Build a Route, throw it away after | Ephemeral; nothing to clean up |
| Multi-tenant SaaS where each tenant's URLs are unknown ahead of time | Can't pre-register | Ephemeral handles unknown URLs at runtime |
## How it differs from Routes
| | Routes | Ephemeral |
| - | - | - |
| **Setup** | Register the URL, method, secrets, environment overrides | None — pass everything per-call |
| **API path** | `/proxy/{route_name}` | `/v1/proxy` |
| **URL** | Fixed at registration | Per-call |
| **Secrets** | Reference by name (configured on the Route) | Reference by name (looked up per-call) |
| **Audit row** | `proxy_mode='route'` | `proxy_mode='ephemeral'` (filterable in API Logs) |
| **Best for** | Integrations you call repeatedly with stable config | One-off calls, agent runtimes, ad-hoc work |
The shared infrastructure (secrets, egress allowlist, rate limits, SSRF guard) applies to **both**.
## Quick start
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/proxy \
-H "Authorization: Bearer $TOKEN" \
-H "X-Knox-Proxy-URL: https://api.example.com/orders" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "{{ token: tok_J8K2M4N5P6Q7R8 }}",
"amount": 4999
}'
```
The HTTP method you use (`POST` above) is forwarded as-is. KnoxCall will:
1. **Read the target URL** from the `X-Knox-Proxy-URL` header.
2. **Resolve `{{ token: ... }}`** references in the body or query string against your [Vaults](/essentials/vaults/overview) (detokenizes, audits the read).
3. **Validate the URL**: HTTPS only, hostname must resolve, IP must not be private/metadata.
4. **Make the call** through KnoxCall's egress (static IP if you've configured one).
5. **Stream the response back** verbatim — same status code, same headers (minus hop-by-hop), same body bytes.
6. **Audit** with `proxy_mode='ephemeral'` so you can filter the calls in API Logs.
## Templating
Template expressions in the **request body** and **query string** are resolved before the call is forwarded:
* `{{ token: }}` — detokenize from a Vault. Token ids are tenant-globally unique, so you don't qualify them with a vault name. Audited per-token.
* `{{ encrypted | json: $.path }}` — reads a value at the given JSONPath out of the base64-encoded JSON you pass in the `X-Knox-Encrypted` request header and substitutes it into the body/query.
* `\{{ ... }}` — an escape that renders a literal `{{ ... }}`. Anything brace-shaped that is *not* one of the two forms above is refused with `400 template_error` instead of being forwarded, so escape braces you intend the upstream to receive.
**Headers are not templated** — the template engine does not process `{{ ... }}` expressions in headers in v1. To inject a secret into an outbound `Authorization` header, use a [Route](/essentials/routes/what-are-routes) instead.
See the [Invoke reference](/api-reference/ephemeral-proxy/invoke) Templates section for the full spec.
## Plan limits
| Tier | Enabled | Ops/mo | RPS limit | Tokens per request |
| - | - | - | - | - |
| Free | no | 0 | 0 | 20 |
| Starter | yes | 100k | 10 | 20 |
| Pro | yes | 1M | 100 | 20 |
| Enterprise | yes | unlimited | unlimited | 100 |
The per-request token cap in the table reflects plan config (Enterprise = 100). In v1 the handler enforces a fixed cap of **20 tokens per request** across all plans; the per-plan Enterprise value is not yet wired into the handler.
The Free tier is **off by default** because the unbounded "any URL with API-key" surface is the most-abused KnoxCall capability. Enterprise tenants who only want pre-configured Routes can override to `false` even on a paid plan.
## Security
* **HTTPS only**. Plain HTTP is rejected.
* **SSRF guard**: hostname must resolve to a public IP. Private (RFC1918), link-local, loopback, and metadata IPs (`169.254.169.254`) are blocked.
* **Tenant scoping**: secrets and Vault tokens are scoped to your tenant; ephemeral calls can't reference other tenants' material.
* **Audit**: every call hits API Logs with `proxy_mode='ephemeral'`.
## Next steps
* [Routes overview →](/essentials/routes/what-are-routes) — for repeated calls with stable config
* [Vaults →](/essentials/vaults/overview) — what `{{ token: ... }}` references
* [Secrets →](/essentials/secrets/secrets-overview) — what `{{ secret: ... }}` references
# Inbound Webhook format guide
Source: https://docs.knoxcall.com/essentials/inbound-webhooks/format-guide
Wire-level details for each HMAC format KnoxCall verifies — header names, signed-payload construction, replay-protection behaviour.
# Inbound Webhook format guide
This page is the wire-level reference for each format the [Inbound Webhooks verifier](/essentials/inbound-webhooks/overview) supports. If you're integrating a custom provider, the rules below tell you exactly what KnoxCall computes when it verifies a request.
All formats use **HMAC-SHA256** with the shared secret you configured on the subscription. Comparisons are constant-time (`crypto.timingSafeEqual`) — there's no timing oracle to exploit.
## Stripe
**Header:** `Stripe-Signature: t=,v1=` (multiple `v1=...` pairs accepted — Stripe rotates secrets this way during a cutover window)
**Signed payload:** `.` — the `t=` value, a literal `.`, then the raw request body.
**Replay protection:** yes — KnoxCall rejects with `replay_window_exceeded` if `now - t > replay_window_seconds`. Default 300 (5 min) matches Stripe's recommendation.
**Compatibility:** anything that adopted Stripe's signature format. The verifier accepts a request when **any** of the listed `v1=` values matches; that's how Stripe handles secret rotation. If you rotate the secret on KnoxCall, an in-flight cutover where the upstream sends both old and new signatures will keep working until you update the upstream config.
## GitHub
**Header:** `X-Hub-Signature-256: sha256=`
**Signed payload:** the raw body bytes only.
**Replay protection:** no — GitHub webhooks include a delivery ID but don't sign it, so KnoxCall can't enforce a timestamp window. If your application can't tolerate replay (e.g. webhooks that mutate state by content rather than idempotency key), enforce the check on your side using GitHub's `X-GitHub-Delivery` header.
**Compatibility:** GitHub webhooks, Postmark, SendGrid Inbound Parse, and most internal SaaS that copied GitHub's scheme.
## Slack
**Headers:**
* `X-Slack-Signature: v0=`
* `X-Slack-Request-Timestamp: `
**Signed payload:** `v0::` — the literal string `v0:`, the timestamp, another `:`, then the raw body.
**Replay protection:** yes — KnoxCall enforces the configured window against `X-Slack-Request-Timestamp`. Default 300 matches Slack's documented recommendation.
**Compatibility:** Slack events, slash commands, interactive components.
## AWS SNS (HMAC variant)
**Headers (outbound):**
* `x-amz-sns-signature: `
* `x-amz-sns-signature-version: 1-hmac`
**Signed payload:** the raw body only.
**Replay protection:** no.
**Inbound verification:** KnoxCall checks only `x-amz-sns-signature`. The `x-amz-sns-signature-version` header is present on outbound deliveries but not required for inbound verification.
> ⚠️ This is **not** real AWS SNS. Real SNS uses RSA over a canonicalised message envelope, not HMAC. KnoxCall's `aws-sns` format is an HMAC variant intended for KnoxCall-to-KnoxCall flows or internal services that wanted SNS-shaped headers without the RSA tooling. If you're verifying real AWS-issued SNS messages, this format won't match — let us know if RSA SNS is a deal-breaker for you.
## KnoxCall legacy
**Header:** `X-Webhook-Signature: sha256=`
**Signed payload:** the raw body only.
**Replay protection:** no.
This is the format KnoxCall's outbound [Webhook Signing](/essentials/webhook-signing/overview) emitted before the multi-format library landed. Use it when both ends are KnoxCall (KnoxCall-to-KnoxCall internal events, or one KnoxCall tenant fanning to another).
## Custom
**Header:** *(caller-named, e.g. `X-MyApp-Signature`)* with value `sha256=`
**Signed payload:** the raw body only.
**Replay protection:** no.
For internal tools that don't match a named format. Set `custom_header_name` when you create the subscription. The verifier only knows that *one* HMAC-SHA256 of the body is in the named header — it doesn't try to be clever about timestamps.
## Reproducing a verification yourself
If you want to confirm KnoxCall's behaviour from the outside (or reproduce why a request failed), here's the GitHub-format check in seven lines of Node:
```javascript theme={"dark"}
import crypto from 'node:crypto';
const secret = 'whsec_...';
const body = '...raw bytes from the request...';
const header = 'sha256=...'; // X-Hub-Signature-256
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
console.log(ok); // → true if KnoxCall would also accept this request
```
For Stripe, the body becomes `${timestamp}.${rawBody}` and the header parses out as `t=...,v1=...`. The Slack version uses `v0:${timestamp}:${rawBody}` and the `X-Slack-Signature: v0=` header.
The verifier's source is not public, so this page is its specification: the per-format rules above are what it computes.
## What happens when verification fails
KnoxCall surfaces the precise reason in the `signature_error` column of API Logs and (when forwarding with `on_invalid='forward_with_error_header'`) in the `X-Knox-Verify-Reason` HTTP header. Common values:
| Reason | Meaning |
| - | - |
| `missing header` | The request didn't include the header KnoxCall expected for this format |
| `malformed ` | The header was present but didn't match the format's regex (e.g. not `sha256=<64-hex>`) |
| `replay_window_exceeded` | Timestamp was outside `[now − replay_window_seconds, now + replay_window_seconds]` |
| `no v1 signature matched` (Stripe) | Header parsed correctly but none of the listed `v1=` values verified — most often a wrong-secret or rotated-secret-on-only-one-side scenario |
| `Stripe-Signature missing t=` | The Stripe header didn't carry a timestamp; usually a misconfigured custom shim claiming to be Stripe |
## Next steps
* [Inbound Webhooks overview →](/essentials/inbound-webhooks/overview)
* [Outbound Webhook Signing →](/essentials/webhook-signing/overview) — the inverse direction
# Inbound Webhooks overview
Source: https://docs.knoxcall.com/essentials/inbound-webhooks/overview
Verify HMAC-signed webhooks from Stripe, GitHub, Slack and others without writing verification code in every service. KnoxCall checks the signature, audits the request, and forwards it to your app.
# Inbound Webhooks overview
KnoxCall's **Inbound Webhooks** feature is a **signature-verifying ingress** for HMAC-signed webhooks. Point Stripe (or GitHub, Slack, AWS SNS, anything that signs requests) at a KnoxCall URL. KnoxCall verifies the signature, audits the call, and optionally forwards the request to your app with an `X-Knox-Verified: true` header.
The job your app *would* do — verify the HMAC, enforce the replay window, log the result — lives once in KnoxCall instead of being copy-pasted across every service that consumes webhooks.
## Why use it
| Problem | Without KnoxCall | With KnoxCall |
| - | - | - |
| Each service that consumes Stripe webhooks reimplements verification | One mistake = signature bypass for that whole service | Verification happens once at the edge, your app trusts the `X-Knox-Verified` header |
| Replay-attack windows (Stripe, Slack) drift across codebases | Easy to forget; SDK upgrades change defaults | KnoxCall enforces the configured window per subscription, not per app |
| Audit trail of "did this webhook signature match?" | Per-app, by log line, often missing | Every ingest writes a row to KnoxCall's API logs with verified=true/false |
| Rotating a webhook secret | Find every service, redeploy each | Rotate in the admin UI; old secret stops working immediately |
| Webhook-source IP filtering | Maintain Stripe/GitHub IP allowlists in every NAT/firewall | Customer-facing URL is on a single hostname, easy to allow |
## How it works
1. **Create an inbound webhook subscription** in the admin UI — pick a format (Stripe, GitHub, Slack, AWS SNS, KnoxCall legacy, or custom-header), paste the shared secret, choose where to forward verified requests.
2. KnoxCall hands you a **receiver URL**: `https://hooks.knoxcall.com//`.
3. Paste that URL into the upstream provider's webhook config. Stripe (or whoever) signs every event with HMAC-SHA256 of the body and sends it to KnoxCall.
4. KnoxCall **verifies the signature** using the format's wire spec and the secret you configured.
5. If valid → forward to your app with `X-Knox-Verified: true` (or audit-only if no forward URL).
6. If invalid → return `401` (default), or forward with `X-Knox-Verified: false` if you want your app to log unverified events without losing them.
Either way, the call lands in **API Logs** so you can see verification rate, replay-rejected requests, and forward latency.
## Quick start (UI)
1. Go to **Automation → Webhooks** in the admin UI, switch to the **Inbound** tab, and click **New Inbound Webhook**.
2. Pick the **format** that matches the upstream provider (Stripe, GitHub, Slack, etc.).
3. Paste the **shared secret** the provider gave you (e.g. Stripe's `whsec_...`).
4. Optional: enter a **forward URL** (your app's webhook handler). Leave blank for verify-only auditing.
5. Save. Copy the **receiver URL** that appears.
6. Paste the receiver URL into the upstream provider's webhook configuration.
That's it. The next event from the upstream provider will hit KnoxCall, get verified, and either show up in your app's webhook handler with `X-Knox-Verified: true` or appear in **API Logs** for inspection.
## Supported formats
| Format | Header | Replay protection | Use case |
| - | - | - | - |
| **Stripe** | `Stripe-Signature: t=,v1=` | yes (timestamp `t=`) | Stripe webhooks, any provider that adopted the format |
| **GitHub** | `X-Hub-Signature-256: sha256=` | no (no timestamp) | GitHub webhooks, Postmark, SendGrid, many SaaS |
| **Slack** | `X-Slack-Signature: v0=` + `X-Slack-Request-Timestamp` | yes | Slack events, slash commands |
| **AWS SNS (HMAC)** | `x-amz-sns-signature` (base64) | no | KnoxCall-shaped SNS receivers (not real AWS RSA-SNS) |
| **KnoxCall legacy** | `X-Webhook-Signature: sha256=` | no | KnoxCall-to-KnoxCall flows |
| **Custom** | caller-named header `sha256=` | no | Internal tools that don't match a named provider |
See the [format guide](/essentials/inbound-webhooks/format-guide) for the exact wire format each verifier expects.
## Replay protection
For formats with a timestamp header (Stripe, Slack), KnoxCall enforces a **5-minute window** by default. A request with a timestamp older than that gets rejected with `replay_window_exceeded` even if the signature is valid. This closes the attack where someone captures a real webhook and replays it later.
Configure the window per subscription (range: 0–86400 seconds, where 0 disables the check). The default of 300 matches Stripe's and Slack's recommended values.
## What gets audited
Every ingest call writes a row to **API Logs** (`/logs`) with:
* `path` — `//`
* `signature_valid` — `true` / `false`
* `signature_error` — the rejection reason if invalid (`replay_window_exceeded`, `no v1 signature matched`, `missing X-Hub-Signature-256 header`, etc.)
* `meta_json.subscription_id` — which subscription handled the request
* `meta_json.forwarded` — whether the verified request was sent on to your app
* `meta_json.forward_status` — HTTP status from your app
Filter the logs by webhook name to see verification health for one provider.
## Security model
KnoxCall stores the shared secret encrypted under the **per-tenant master key** (KCT1 envelope). The receiver URL is **unauthenticated by design** — authentication *is* the HMAC signature. A request that doesn't verify either gets dropped (default) or marked `X-Knox-Verified: false` for your app to handle.
Forwarding is **SSRF-safe**: the forward URL is validated to use HTTPS, point at a DNS hostname (not a literal IP), and not resolve to a private / metadata-IP range.
## Next steps
* [Format guide →](/essentials/inbound-webhooks/format-guide)
* [Outbound Webhook Signing →](/essentials/webhook-signing/overview) — the inverse direction (KnoxCall fires events out and signs them)
* [Crypto Keys →](/essentials/crypto-keys/crypto-keys-overview) — use a managed key for signature verification instead of a per-subscription shared secret
# BYOK and KMS providers
Source: https://docs.knoxcall.com/essentials/key-management/byok-and-kms
Wrap your tenant master key with a KMS key — operator-side or your own (BYOK). Revoke our access in one IAM change.
By default, every KnoxCall tenant's master key is wrapped with the deployment's `MASTER_KEY_B64` (the `global` wrap method). That gives you per-tenant cryptographic isolation, but the wrapping key is on the same compute as KnoxCall.
If your security posture requires the wrapping key to live elsewhere — in a hardware-backed HSM, in a separate AWS account, or under your own IAM control — you opt into **KMS-backed wrapping**. Two flavours:
* **Operator KMS** — KnoxCall manages a KMS key on your behalf (in our cloud account).
* **Customer KMS (BYOK)** — the KMS key lives in **your** cloud account. You can revoke our access in one IAM change.
## When to use each
| If you want… | Pick |
| - | - |
| Default — minimal config, same crypto + per-tenant isolation | `global` (no setup) |
| KMS audit + hardware-backed wrapping, low ops burden | `operator_kms` |
| Maximum sovereignty, kill-switch in your IAM, regulatory requirement | `customer_kms` (BYOK) |
If you don't have a regulatory or sovereignty requirement, `global` is the right answer — it's not weaker crypto, it's just a different blast-radius story.
## What gets wrapped vs encrypted directly
* The **tenant master key** itself is wrapped by your KMS key.
* Everything **inside** KnoxCall — secrets, DB admin passwords, CA private keys, transit-key versions — is encrypted with the **tenant master key** using AES-256-GCM, locally on KnoxCall's compute.
KMS is in the path **once per cache miss** (default 1-hour TTL). It's not in the path of every encrypt / decrypt, so your data ops aren't gated on KMS latency or availability between cache flushes.
## Operator KMS
KnoxCall operates a KMS key in our cloud account. Tenant master keys are wrapped with it. You don't manage IAM, the key, or rotation — we do.
* ✅ Hardware-backed wrapping with KMS audit.
* ✅ Minimal config — pick the provider, we handle the rest.
* ⚠️ KnoxCall holds the wrapping key. Revoking access requires reaching out to us (or destroying the tenant entirely, which works because of cryptographic erasure).
Pick this if you want KMS-grade wrapping but don't need a kill switch in your own cloud account.
## Customer KMS (BYOK)
The wrapping key lives in **your** AWS / GCP / Azure account. KnoxCall is granted IAM permission to call `Encrypt` / `Decrypt` against that one key — nothing else.
* ✅ Strongest sovereignty: revoke our IAM grant and we can't unwrap on the next cache miss.
* ✅ Your KMS audit log records every unwrap call we make.
* ✅ Required by some regulated buyers (FedRAMP, HIPAA, certain EU data-residency stories).
* ⚠️ You're responsible for IAM, key rotation, and not breaking it during your own cloud changes.
When you "lock us out" by revoking the grant, every tenant whose master key is wrapped by that KMS key goes [sealed](/essentials/key-management/tenant-master-key#sealed-state) on the next cache miss. The cache TTL bounds how long stale unwraps remain usable.
## Setup: AWS BYOK
For the full step-by-step setup guide including GCP and Azure, see the [Tenant KMS (BYOK) section](/essentials/tenant-kms/overview). This page covers the concepts and background; the Tenant KMS section has the operational runbooks.
Customer KMS (BYOK) tenants have a **5-minute session cache TTL** instead of the default 1 hour. Agent sessions renew more frequently — plan for this in self-hosted proxy deployments.
### 1. Create a KMS key in your AWS account
```bash theme={"dark"}
# In your AWS account, your region:
aws kms create-key \
--description "KnoxCall tenant master key wrapping" \
--key-usage ENCRYPT_DECRYPT \
--key-spec SYMMETRIC_DEFAULT
# → Note the KeyId / Arn
```
### 2. Grant KnoxCall's IAM principal access
KnoxCall assumes a role in your account via STS using a customer-supplied `role_arn` and `external_id`. Create a role with this trust policy in **your** account:
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam:::root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "" }
}
}]
}
```
KnoxCall shows `` in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, AWS step) along with a ready-to-paste copy of this trust policy; it's also returned by `GET /admin/tenant-kms/provider-identity`. The `` is anything you generate (UUID is fine) — you'll paste it into the admin UI.
Attach this inline policy to the role, restricted to the one KMS key:
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:Decrypt"],
"Resource": ""
}]
}
```
### 3. Configure the tenant in KnoxCall
In the admin UI: **Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, select **AWS KMS**. Provide:
* **AWS region** of the KMS key.
* **KMS key ARN** (or alias ARN).
* **Role ARN** of the role you created in step 2.
* **External ID** matching the trust policy.
Or via API:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-...",
"config": {
"region": "us-east-1",
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess",
"external_id": "your-chosen-external-id"
}
}'
```
### 4. KnoxCall verifies the key
On save, KnoxCall does a probe encrypt + decrypt round-trip to prove the IAM grant works. The result lands in `last_verified_at` / `last_verify_error` on the config row. If verification fails, the change isn't applied — you don't end up with a half-broken config.
### 5. Provision the tenant master key under the new wrap
After verification succeeds, KnoxCall provisions a new tenant master key version wrapped with the KMS key. The new version becomes active; the previous (`global`-wrapped) version is retired.
**Existing data stays wrapped under the retired version** until a rewrap migration runs. New writes use the new active version.
## What credentials look like in the config
Per the schema, `tenant_kms_config.config` is **non-secret configuration only** — region, role\_arn, external\_id. Static credentials (AWS access keys, GCP service account JSON files, Azure client secrets) are **rejected at the API level** — not just discouraged. Cross-account access is always via STS-assumed roles for `customer_kms`. Operator KMS uses the deployment's IAM role.
## Provider support today
| Provider | Status | Notes |
| - | - | - |
| `global` | ✅ Shipped | Default; wraps with `MASTER_KEY_B64`. |
| `operator_kms_aws` | ✅ Shipped | KnoxCall-managed AWS KMS key. |
| `customer_kms_aws` | ✅ Shipped | BYOK in your AWS account. STS cross-account. |
| `operator_kms_gcp` | ✅ Shipped | KnoxCall-managed GCP Cloud KMS key. |
| `customer_kms_gcp` | ✅ Shipped | BYOK in your GCP project. Workload Identity Federation. |
| `operator_kms_azure` | ✅ Shipped | KnoxCall-managed Azure Key Vault key. |
| `customer_kms_azure` | ✅ Shipped | BYOK in your Azure subscription. Federated Credentials. |
## Revoking BYOK access (the kill switch)
If you ever need to lock KnoxCall out:
1. Remove or detach the IAM policy that lets the assumed role call `kms:Decrypt` on your key.
2. Within the cache TTL (**5 minutes** for BYOK tenants), every tenant under this BYOK config will go sealed.
3. New agent sessions fail; existing agents continue until their current session expires (at most 5 minutes).
Revoke the **key permission**, not just the identity. Permission checks (`kms:Decrypt` on AWS, the KMS role binding on GCP, the Key Vault role assignment on Azure) are evaluated on every call, so they take effect within the 5-minute cache TTL. Revoking only the *identity path* (the AWS role's trust policy, a GCP Workload Identity binding, an Azure federated credential) blocks new sessions but not credentials KnoxCall already holds — those persist up to \~15 minutes on AWS, \~5 minutes on GCP, and up to your Entra access-token lifetime (typically \~60–90 minutes) on Azure. Doing both is fine; the key permission is the one that guarantees the window.
To **destroy** the data instead of just freezing it: revoke the **tenant master key** itself in KnoxCall (cryptographic erasure — see [tenant master key](/essentials/key-management/tenant-master-key#cryptographic-erasure-the-kill-switch)). The KMS key in your account is then irrelevant — the master key is gone.
## See also
* [Tenant master key →](/essentials/key-management/tenant-master-key)
* [Tenant KMS (BYOK) overview →](/essentials/tenant-kms/overview)
# Tenant master key
Source: https://docs.knoxcall.com/essentials/key-management/tenant-master-key
Every tenant has its own master key. It wraps everything else KnoxCall encrypts on your behalf — and it's the lever for cryptographic erasure.
# Tenant Master Key
Every KnoxCall tenant has its own **master key** — a 32-byte symmetric key that wraps every other piece of encrypted material the tenant owns. Secrets, database admin passwords, SSH keys, CA private keys, transit-key versions: all of it is wrapped (KCT1 envelope format) by the tenant master key.
This is the load-bearing isolation primitive in KnoxCall. A bug in the control plane, a stolen backup, or a malicious insider hitting the wrong row in the database can't leak data across tenants — every tenant's data is wrapped with **its** key, and you can't unwrap one tenant's blob with another tenant's key.
The Vault parallel: this is your unseal key, except per-tenant rather than per-cluster, and unwrapped on demand from a configurable backing store rather than at startup.
## How it's stored
The master key isn't stored in plaintext anywhere. It's stored **wrapped** in the `tenant_master_keys` table, and the wrapping method is configurable per tenant. There are three:
| Wrap method | What wraps the master key | Where the wrapping key lives | Sovereignty |
| - | - | - | - |
| `global` | `MASTER_KEY_B64` (the deployment's env var) | KnoxCall's process env | Default — same as today, plus per-tenant isolation |
| `operator_kms` | A KMS key in **KnoxCall's** cloud account | AWS / GCP / Azure (KnoxCall-operated) | Strong (KMS audit, hardware-backed) |
| `customer_kms` | A KMS key in **your** cloud account | AWS / GCP / Azure (yours) | Strongest — [BYOK](/essentials/key-management/byok-and-kms) |
See the [BYOK & KMS guide](/essentials/key-management/byok-and-kms) for how to set up `operator_kms` or `customer_kms`. Without explicit configuration, every tenant uses `global` — same crypto today, plus per-tenant isolation.
## How it's used
Every per-tenant crypto operation goes through `TenantMasterKeyManager`. The flow:
1. Caller wants to encrypt or decrypt something for tenant `T`.
2. Manager checks its in-memory cache for `T`'s unwrapped master key.
3. **Cache hit** → use the cached key.
4. **Cache miss** → load the wrapped row, dispatch to the correct provider (global / AWS / future GCP / future Azure) to unwrap, cache for an hour.
5. Concurrent misses for the same tenant **deduplicate** — N parallel requests after a cache flush trigger one unwrap, not N.
The cache TTL is 1 hour by default for `global` and `operator_kms` wrap methods; customer-tunable in `[60, 3600]` seconds for those methods. **Exception: `customer_kms` (BYOK) tenants have a fixed 5-minute TTL** — this is not customer-tunable, and is intentional to bound plaintext exposure if the customer revokes KMS access.
Two practical implications:
* **First call after a cache miss is slow** (KMS round-trip). Subsequent calls are local.
* **You can revoke KnoxCall's KMS access mid-flight**, and the next cache flush makes the tenant immediately unable to unwrap anything. The tenant goes "sealed."
## Sealed state
If unwrapping fails (KMS access denied, KMS unreachable, key disabled), the manager records `tenant_kms_config.sealed_since`. For `customer_kms` (BYOK) tenants, the 5-minute cache TTL means the tenant becomes sealed within 5 minutes of losing KMS access. While sealed:
* New encrypts / decrypts for that tenant fail with a clear error.
* The cache still serves any unexpired entries — the tenant doesn't go dark instantly, only after the cache TTL.
* The admin UI's Key Management section shows the sealed state and the last KMS error.
The sealed state clears automatically on the next successful unwrap.
## Versioning
Master keys are versioned per tenant. Each rotation creates a new row at the next version, marks the previous one `retired`, and sets the new one `active`.
* **Active** — current version. New wraps use this. One per tenant.
* **Retired** — older but still usable for unwrap of legacy ciphertext.
* **Revoked** — explicitly destroyed. Cryptographic erasure: data wrapped by this version is **permanently unreadable**.
## Cryptographic erasure (the kill switch)
Revoking a tenant master key version is destructive. It's the right-to-be-forgotten primitive, and it cascades:
```text theme={"dark"}
Revoke tenant master key v1
↓
Every CA private key wrapped by v1 → unreadable
Every secret blob wrapped by v1 → unreadable
Every transit-key version wrapped by v1 → unreadable
Every database admin password wrapped by v1 → unreadable
```
The data **rows** stay in the database for audit, but the wrapped blobs can never be unwrapped again. Re-issuing a tenant master key won't bring them back — fundamentally different bytes.
The admin UI guards revocation with a confirm-by-typing-tenant-name dialog. The audit-log entry records who, when, and why.
## What never leaves KnoxCall
These never appear in any API response:
* The unwrapped master key bytes.
* The wrapped blob (visible in DB rows; not exposed to API).
* The KMS provider's response payload.
The Key Management endpoints return **metadata only** — version, wrap method, KMS key reference (the ARN, not the key), sealed state, timestamps. There is no `GET /admin/key-management/keys/{version}/material` and there never will be.
## Audit-trail markers
| Audit action | What it means |
| - | - |
| `tenant_master_key.create` | New master key minted (rotation or initial provision) |
| `tenant_master_key.kms_config_update` | KMS config changed (e.g. switched from `global` to `customer_kms_aws`) |
| `tenant_master_key.revoke` | Cryptographic erasure of a version |
| `tenant_master_key.cache_clear` | Operator forced an unwrap on next access (e.g. to verify KMS still works) |
| `byok.kms.onboard` | Customer KMS provider onboarded (BYOK) |
| `byok.kms.rotate` | Tenant master key rotated under BYOK config |
| `byok.kms.unseal` | Tenant unsealed after KMS access restored (seal itself is not logged — only `sealed_since` is set on the config row) |
## What's shipped today
* ✅ Per-tenant master keys for all three wrap methods (rows, manager, cache, sealed-state).
* ✅ `global` wrap (the deployment-wide `MASTER_KEY_B64`).
* ✅ AWS KMS provider (`operator_kms_aws`, `customer_kms_aws`).
* ✅ GCP Cloud KMS provider (`operator_kms_gcp`, `customer_kms_gcp`) — Workload Identity Federation.
* ✅ Azure Key Vault provider (`operator_kms_azure`, `customer_kms_azure`) — Federated Credentials.
* ✅ Cryptographic erasure with audit trail.
* ✅ Tenant KMS dashboard — manage BYOK config at Settings → Security → Tenant Master Key → Customer KMS.
* ⏳ Bulk rewrap migration: when you rotate a master key, existing data stays wrapped under the retired version. A migration tool to rewrap everything to the new active version is on the roadmap (the manual escape valve is to do it via `rewrap` / per-resource update).
## See also
* [BYOK & KMS providers →](/essentials/key-management/byok-and-kms)
* [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — what `cloud-only` vs `bundled` means in the context of the master key
* [Certificate Authority overview →](/essentials/pki/pki-overview) — how the master key protects CA private keys
# Agent database proxy protocols
Source: https://docs.knoxcall.com/essentials/migrations/agent-db-proxy
Technical reference for the KnoxCall agent's localhost database proxy — supported protocols, peer attestation, TLS handling, and wire-level details.
# Agent database proxy protocols
The KnoxCall agent's database proxy binds one TCP listener per configured route on `127.0.0.1`. Applications connect as if they're connecting to the database directly. The agent handles authentication upstream and pipes the connection through.
This page covers the wire-level details for each supported protocol.
## General architecture
```text theme={"dark"}
Application (any process on the same host)
↓ TCP to 127.0.0.1:{port}
Agent listener
↓ SO_PEERCRED UID check
↓ Fetch credentials from KnoxCall session bundle
↓ TLS connect to upstream DB host:port
↓ Perform auth handshake on behalf of application
↓ Pipe post-auth bytes bidirectionally until connection closes
Upstream database
```
## Local-only binding guarantee
The agent **refuses to start** if any proxy port is configured to bind on a non-loopback interface. There is no override flag. If your deployment requires cross-host access, use a network namespace or a dedicated proxy tier — do not configure the agent to bind to `0.0.0.0`.
For container deployments, run the agent in the same network namespace as the application (sidecar pattern). The `127.0.0.1` binding then scopes to the shared namespace.
## Peer attestation
On Linux, the agent checks `SO_PEERCRED` on every new connection to retrieve the connecting process's UID. If a UID allowlist is configured on the route and the connecting UID is not in the list, the connection is refused immediately before any bytes are read.
On Windows, named pipes with an explicit ACL replace `SO_PEERCRED`. The behaviour is equivalent.
If no UID allowlist is configured, any process on localhost can connect. Configure a UID allowlist in production environments where multiple users share the same host.
## PostgreSQL
**Auth method**: ScramSHA-256
1. Agent receives the client's startup packet (protocol 3.0)
2. Agent stores the requested database name and user field from the startup packet
3. Agent opens TLS to the upstream PostgreSQL server
4. Agent performs a full ScramSHA-256 handshake with the upstream server using KnoxCall credentials
5. After `AuthenticationOk`, agent sends `ReadyForQuery` back to the application
6. From this point, agent pipes bytes bidirectionally — no further inspection
The application's own startup packet parameters (database, user, application\_name) are forwarded to upstream where safe. The password field in `PasswordMessage` is never forwarded — the agent substitutes its own credentials.
**TLS modes**:
| Mode | Description |
| - | - |
| `require` | Strict TLS — upstream cert validated against system CA bundle + any custom CAs in KnoxCall |
| `prefer` (default) | Try TLS first; fall back to plain TCP if the upstream doesn't support it. Cert not validated. |
| `disable` | Plain TCP — no TLS to upstream |
## MySQL
**Auth method**: `CLIENT_PLUGIN_AUTH` with `caching_sha2_password` or `mysql_native_password`
1. Agent receives the client's connection request (no auth info — MySQL initiates auth from server side)
2. Agent opens TLS to upstream MySQL
3. Agent responds to the server's auth challenge using KnoxCall credentials
4. After server sends `OK_Packet`, agent pipes bidirectionally
The agent negotiates `CLIENT_SSL` with the upstream server regardless of the application-to-agent TLS setting. The application always connects to `127.0.0.1` unencrypted; the agent always connects to upstream encrypted.
## MongoDB
**Auth method**: SCRAM-SHA-256 via `OP_MSG`
1. Agent receives the client's `isMaster` / `hello` command
2. Agent opens TLS to upstream MongoDB
3. Agent performs SASL SCRAM-SHA-256 authentication using KnoxCall credentials
4. After auth succeeds, agent pipes all subsequent `OP_MSG` frames bidirectionally
MongoDB connection strings in the application: `mongodb://127.0.0.1:{port}/{dbname}` — no username or password in the URI.
For MongoDB Atlas clusters, the upstream address is the SRV hostname. The agent resolves SRV records and connects to the primary.
## Redis
**Auth method**: `AUTH` command injection
1. Agent accepts the TCP connection from the application
2. Agent opens TLS to upstream Redis
3. Before forwarding any application commands, agent sends `AUTH username password` (Redis 6+ ACL) or `AUTH password` (Redis 5 and below) using KnoxCall credentials
4. After `+OK`, agent pipes all subsequent RESP frames bidirectionally
The application does not send any `AUTH` command — the agent has already authenticated. If the application sends an `AUTH` command, the agent intercepts it and returns `+OK` without forwarding (to prevent auth token leakage).
For Redis Cluster, the agent connects to one seed node and supports `MOVED` / `ASK` redirects transparently.
## TLS to upstream
The agent always connects to the upstream database over TLS, regardless of how the application connects to the agent. Certificate validation behaviour:
* **strict** (default): certificate chain validated against the system CA bundle plus any custom CAs stored in KnoxCall PKI
* **skip\_verify**: validation disabled — only use for local development databases with self-signed certificates
* **mtls**: agent presents a client certificate (stored as a KnoxCall certificate secret) during the TLS handshake
## Telemetry
The agent emits `migration.db_proxy_probe` telemetry events to KnoxCall during the verification window. Each probe reports whether the upstream database is still being reached through the legacy credentials:
| `event_type` | Probe type values | `outcome` values |
| - | - | - |
| `migration.db_proxy_probe` | `dns_probe`, `tls_probe`, `connection_attempt`, `no_traffic_window` | `still_reachable`, `nxdomain`, `connection_refused`, `tls_error`, `clean`, `unknown` |
Probe events are stored in the migration verification log (visible on the Migration Verification page) and inform the safe-to-delete verdict. They do not appear in the main Audit Logs.
Setting up DB proxy routes for a migration
Probe results and safe-to-delete confirmation
Agent installation and configuration
Store client certs for mTLS upstream connections
# Bucket A: HTTPS API migration
Source: https://docs.knoxcall.com/essentials/migrations/bucket-a-https
Zero-plaintext cutover for applications that call cloud secret store APIs directly. The agent intercepts, rewrites auth, and serves secrets from KnoxCall — no code changes required.
# Bucket A: HTTPS API migration
Bucket A handles migrations where your applications call a cloud secret store API (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, AWS SSM) directly over HTTPS. The KnoxCall agent intercepts these calls before they leave the host, rewrites the authentication header, and returns the secret from KnoxCall instead.
**Your application code does not change.**
## How it works
```text theme={"dark"}
Application
↓ HTTPS call to secretsmanager.us-east-1.amazonaws.com
KnoxCall agent (intercept mode)
↓ Validates app identity (SO_PEERCRED or process UID)
↓ Rewrites auth header: AWS SigV4 → KnoxCall Bearer token
↓ Forwards to KnoxCall proxy
KnoxCall proxy
↓ Injects real secret value
↓ Returns response in AWS SM wire format
Application receives response — identical to a real AWS SM response
```
The agent acts as a transparent MITM on the loopback interface. The application's AWS SDK believes it's talking to AWS Secrets Manager; the agent translates the call to KnoxCall's API and returns a response in the expected format.
No plaintext secret value ever touches disk. The agent holds secrets in memory only for the duration of the intercept.
## Supported capture surfaces
| Surface | How interception works |
| - | - |
| **K8s CSI volume driver** | Agent sidecar intercepts filesystem reads from the CSI mount |
| **External Secrets Operator (ESO)** | Agent intercepts ESO's API calls to the provider |
| **ECS / Lambda environment injection** | Agent intercepts metadata endpoint calls at runtime |
| **Cloud sidecar agents** | Agent intercepts AWS AppConfig, Azure App Configuration, GCP Config Connector calls |
| **TLS interception** | Agent proxies all outbound TLS on configured ports; matches secret store hostnames |
## Setting up intercept rules
Intercept rules tell the agent which outbound requests to intercept and how to rewrite them.
### Via admin UI
1. Go to **Infrastructure → Secret Store Migrations**
2. Open the migration → click the **Intercept Rules** tab
3. Click **Add rule**
4. Configure:
* **Match host**: e.g. `secretsmanager.us-east-1.amazonaws.com`
* **Match path prefix**: e.g. `/`
* **Rewrite**: select the KnoxCall secret to substitute
5. Save — the agent picks up new rules within 30 seconds
### Example rule (AWS SM → KnoxCall)
```json theme={"dark"}
{
"match_host": "secretsmanager.us-east-1.amazonaws.com",
"match_path_prefix": "/",
"rewrite_secret_id": "sec_01abc...",
"response_format": "aws_secrets_manager"
}
```
The `response_format` field tells the agent to return the value in the format the application expects (AWS SM JSON envelope, plain value, Azure KV JSON, etc.).
## Zero-plaintext guarantee
* The secret value is never written to disk, logged, or stored in the `migration_intercept_rules` table
* The agent holds the value in memory only for the lifespan of a single request
* If the agent process crashes mid-intercept, the application receives a connection error — not a partial or logged secret
## Monitoring migration progress
The `secret_consumers` table tracks discovered workloads that are consuming each migrated secret:
```bash theme={"dark"}
curl https://api.knoxcall.com/admin/migrations/mgr_01abc.../verification-events \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
The admin UI shows a consumer count per secret, and highlights any consumers not yet seen through the intercept path.
## Migration status progression
| Status | What it means |
| - | - |
| `pending` | Migration created; discovery not yet started |
| `discovering` | Agent scanning workloads; no interception active yet |
| `awaiting_review` | Discovery complete; items queued for reviewer decisions |
| `completed` | All approved items committed to KnoxCall secrets |
## Cutover
When the verification dashboard shows all consumers green and all items have been approved:
1. In the admin UI, click **Commit migration** (or call `POST /admin/migrations/:id/commit`)
2. Wait for the `completed` status
3. You can now safely revoke cloud store credentials and remove secrets from the source provider
Do not revoke the cloud store credentials until you have confirmed all application instances are routing through KnoxCall. Intercept rules route live traffic — removing them before cutover confirmation will break applications.
## Cancelling a Bucket A migration
You can cancel a migration in `pending`, `discovering`, or `awaiting_review` state:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../cancel \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
This removes all intercept rules. Applications revert to direct cloud store access immediately.
For database credentials
DNS/TLS probes and safe-to-delete confirmation
Intercept rules and migration endpoints
Back to overview
# Bucket B: Database migration
Source: https://docs.knoxcall.com/essentials/migrations/bucket-b-databases
Migrate database credentials from cloud secret stores via the KnoxCall agent's localhost TCP proxy. Change one connection string, nothing else.
# Bucket B: Database migration
Bucket B handles database credentials stored in a cloud secret store. The KnoxCall agent binds a local TCP listener on `127.0.0.1`. Your application connects to `127.0.0.1:{agent_port}` — the agent authenticates to the real database using KnoxCall-managed credentials, then pipes the connection through bidirectionally.
**The only application change: update the database connection string.** No credential rotation, no SDK changes, no code changes beyond the host and port.
## How it works
```text theme={"dark"}
Application
↓ TCP connection to 127.0.0.1:5432 (agent listener)
KnoxCall agent
↓ Validates connecting process UID (SO_PEERCRED)
↓ Fetches DB credentials from KnoxCall
↓ Opens TLS connection to upstream DB (db.prod.internal:5432)
↓ Performs authentication on behalf of the application
↓ Pipes post-auth bytes bidirectionally
Upstream database ← TLS ← Agent ← plaintext ← Application
```
The application never sees the database password. The agent handles the entire authentication handshake.
The agent **only ever binds `127.0.0.1`**, never `0.0.0.0`. If you configure a proxy route with a non-loopback bind address, the agent refuses to start. This is enforced — not advisory.
## Supported protocols
| Protocol | Default port range | Notes |
| - | - | - |
| PostgreSQL | 5432–5440 | Full wire protocol; ScramSHA-256 upstream auth |
| MySQL | 3306–3316 | Full wire protocol; CLIENT\_PLUGIN\_AUTH |
| MongoDB | 27017–27027 | OP\_MSG with SCRAM auth |
| Redis | 6379–6389 | RESP protocol; AUTH command injection |
Port ranges are configurable per route. Each database gets its own port on the agent.
## Application change required
Update the connection string in your application's configuration:
```diff theme={"dark"}
- DATABASE_URL=postgresql://db.prod.internal:5432/mydb
+ DATABASE_URL=postgresql://127.0.0.1:5432/mydb
```
No username or password needed in the connection string — the agent provides them. Most database drivers support passwordless connections when the server doesn't challenge (the agent handles the challenge upstream).
For connection poolers like PgBouncer or ProxySQL running in front of the database, update the pooler's upstream to `127.0.0.1:{agent_port}` instead. The application points at the pooler as usual.
## Setting up a DB proxy route
### Via admin UI
1. Go to **Infrastructure → Secret Store Migrations**
2. Open the migration → click the **DB Proxy Routes** tab
3. Click **Add route**
4. Configure:
* **Protocol**: PostgreSQL / MySQL / MongoDB / Redis
* **Local port**: the port the agent will bind on `127.0.0.1`
* **Upstream host**: your real database hostname
* **Upstream port**: real database port
* **Credentials secret**: the KnoxCall secret holding the database username/password
* **UID allowlist** (optional): Linux UIDs permitted to connect to this proxy port
### Via API
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../db-proxy-routes \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"protocol": "postgres",
"local_port": 5432,
"upstream_host": "db.prod.internal",
"upstream_port": 5432,
"committed_secret_id": "sec_01abc...",
"allowed_uids": [1000, 1001]
}'
```
## Security model
### Peer attestation
The agent validates the UID of every connecting process via `SO_PEERCRED` on Linux (or named-pipe ACL on Windows). If the connecting process UID is not in the allowlist, the connection is refused before any data is exchanged.
### TLS to upstream
The agent always opens TLS to the upstream database — even when the application connects to the agent over unencrypted localhost. The agent strictly validates the upstream certificate by default. For development environments where the DB has a self-signed cert, TLS verification can be relaxed per route (not recommended for production).
### mTLS
If the upstream database requires client certificates for mutual TLS, store the client certificate as a KnoxCall certificate secret and reference it in the route config. The agent presents the certificate automatically during the TLS handshake.
## Verification and cutover
Once your application is routing through the agent proxy:
1. The [migration verification dashboard](/essentials/migrations/verification) tracks probe results showing the old-provider endpoint becoming unreachable
2. When all probes pass and consumers are confirmed, click **Confirm migration complete**
3. The "safe to delete cloud secrets" banner appears
At that point, the database credentials in the cloud secret store can be rotated or deleted.
## Cancelling a Bucket B migration
Cancelling removes all DB proxy routes. Applications that were connecting through the agent will lose database connectivity immediately. Only cancel if you're reverting the migration entirely.
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../cancel \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
Wire-level details: ScramSHA-256, SCRAM, AUTH injection
Probe results and safe-to-delete confirmation
For HTTP-based secret store consumers
DB proxy route endpoints
# Secret Store Migrations overview
Source: https://docs.knoxcall.com/essentials/migrations/overview
Migrate secrets from AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager into KnoxCall with zero downtime and zero plaintext exposure.
# Secret Store Migrations overview
If your secrets currently live in AWS Secrets Manager, AWS SSM Parameter Store, Azure Key Vault, or GCP Secret Manager, the Secret Store Migration feature lets you move them into KnoxCall — without changing application code and without any secrets ever touching disk during the migration.
The goal: KnoxCall becomes the single place your team manages, audits, and rotates credentials, regardless of which cloud provider currently holds them.
## How it works
The KnoxCall agent runs alongside your applications. During a migration, the agent acts as an intermediary:
* **Bucket A (HTTPS APIs)**: the agent intercepts API calls your application makes to the cloud secret store, rewrites the authentication, and forwards the secret from KnoxCall instead. No code change in your app.
* **Bucket B (Databases)**: the agent binds a local TCP port on `127.0.0.1`. Your application connects to `127.0.0.1:{port}` instead of the database host directly. The agent handles all authentication to the real database and pipes the connection through.
| | Bucket A — HTTPS APIs | Bucket B — Databases |
| - | - | - |
| **Mechanism** | Agent TLS intercept + auth header rewrite | Agent TCP proxy (localhost listener) |
| **Code changes required** | None | Change connection string to `127.0.0.1:{port}` |
| **Source stores** | AWS SM, AWS SSM, Azure KV, GCP SM | Any database with credentials in those stores |
| **Protocols** | HTTPS | PostgreSQL, MySQL, MongoDB, Redis |
| **Plaintext exposure** | Never | Never |
## Migration phases
Every migration moves through this lifecycle:
```text theme={"dark"}
pending → discovering → awaiting_review → completed
```
| Status | What's happening |
| - | - |
| `pending` | Migration created; discovery job not yet started |
| `discovering` | Agent or pull worker scanning the secret store for credentials |
| `awaiting_review` | Discovery complete; items are queued for reviewer decisions (approve / reject) |
| `completed` | All approved items written to KnoxCall secrets; migration closed |
| `cancelled` | Migration cancelled by operator |
You can cancel a migration in the `pending`, `discovering`, or `awaiting_review` phase. Once `completed`, the migration is terminal.
## Source stores supported
| Provider | API secrets | DB credentials |
| - | - | - |
| AWS Secrets Manager | ✅ Bucket A | ✅ Bucket B |
| AWS SSM Parameter Store | ✅ Bucket A | — |
| Azure Key Vault | ✅ Bucket A | ✅ Bucket B |
| GCP Secret Manager | ✅ Bucket A | ✅ Bucket B |
## Who can review a migration
Revealing an imported item's value, approving or rejecting an item, and committing a batch each
need **both** of these:
* **The migration-review delegation.** The Owner always holds it; an Admin holds it only if the
Owner granted it. Nobody else can review.
* **The reveal chokepoint, Class B.** Owner or Admin by role, and **one** fresh second-factor
verification per action, made for the migration review (`"action_scope": "secret.migration"`;
a verification made to reveal a secret does not count here, and one made here counts nowhere
else). Each action writes a `plaintext.reveal` audit entry before anything is decrypted or
changed, and counts against the per-person reveal budget (60 per 5 minutes). If the audit
entry cannot be written or the budget cannot be counted, the action is refused.
Revealing an item still also writes its own critical audit entry and emails the Owner when someone
else reveals it.
## Quick start
### Via admin UI
1. Go to **Infrastructure → Secret Store Migrations**
2. Click **New Migration**
3. Choose your source provider and bucket type
4. Follow the setup wizard
### Via API
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws_sm",
"mode": "pull",
"credentials": {
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallMigrationReader",
"external_id": "your-chosen-external-id"
},
"scope": {
"regions": ["us-east-1"]
}
}'
```
Provider values: `aws_sm`, `aws_ssm`, `azure_kv`, `gcp_sm`. Mode values: `pull`, `capture`, `both`. Static credentials (`access_key_id`, `client_secret`, etc.) are rejected — use IAM role assumption or WIF. See the [Migrations API reference](/api-reference/migrations) for full field documentation per provider.
Zero-plaintext cutover for HTTP/HTTPS secret store consumers
Localhost TCP proxy for database credential migration
Technical reference for PostgreSQL, MySQL, MongoDB, Redis wire protocols
DNS/TLS probes, operator confirmation, and safe-to-delete status
Full endpoint reference
Install and configure the self-hosted proxy agent
# Migration verification
Source: https://docs.knoxcall.com/essentials/migrations/verification
Run DNS and TLS probes to confirm old-provider endpoints are unreachable, review the timeline, and get the safe-to-delete banner before cutting over.
# Migration verification
Before KnoxCall marks a migration complete, verification confirms that:
1. Old-provider endpoints are becoming unreachable (your applications have stopped calling them)
2. All known consumers have been seen routing through KnoxCall
3. An operator has explicitly confirmed the migration is clean
## The verification dashboard
Navigate to **Infrastructure → Secret Store Migrations → your migration → Verification tab**.
The dashboard shows:
* **Per-probe timeline** — a chronological list of DNS and TLS probe results, one row per probe
* **Consumer status** — which workloads have been seen using the KnoxCall path vs still hitting the old provider
* **Operator confirmation** — a button that unlocks once probes are green
* **Safe-to-delete banner** — appears when all three conditions above are met
## DNS and TLS probes
KnoxCall runs two probe types against each migrated secret's original provider endpoint:
| Probe type | What it checks | "Pass" condition |
| - | - | - |
| **DNS** | DNS resolution of the old provider hostname (e.g. `secretsmanager.us-east-1.amazonaws.com`) | ✅ Resolves correctly (we want it to still resolve during migration) |
| **TLS** | TLS handshake attempt to the old provider | ✅ Handshake succeeds (again — expected during migration phase) |
Probes run every 5 minutes during an active migration. The verification dashboard uses the **absence** of consumer traffic to the old endpoint (not unreachability) as the primary signal — "pass" means the endpoint is still reachable but your apps are no longer hitting it.
The probes don't check if your apps are calling the old endpoint — that tracking comes from the agent's `secret_consumers` table. Probes check reachability for operational awareness (e.g. if the cloud provider decommissions an API endpoint mid-migration).
## Operator confirmation
Once all items have been reviewed and approved, and consumer status shows all known workloads routing through KnoxCall:
1. Click **Commit migration** in the admin UI
2. Or call the API:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../commit \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
This writes approved items as live KnoxCall secrets. The migration must be in `awaiting_review` status with at least one approved item.
One request commits up to `batch_size` items (250); a larger migration stays in `awaiting_review` with a non-zero `remaining_count`, and you repeat the call — in the admin UI, click **Commit migration** again — until it reaches `0` and the status becomes `completed`.
Response:
```json theme={"dark"}
{
"id": "mgr_01abc...",
"status": "completed",
"committed_count": 30,
"remaining_count": 0,
"batch_size": 250
}
```
## Safe-to-delete guidance
Once a migration reaches `completed` status, it is safe to clean up the source cloud store when all of the following hold:
* All tracked consumers have been seen routing through KnoxCall at least once
* Verification events show no probe errors in the last 24-hour window
* The commit has succeeded (`status: "completed"`)
At that point:
* You can revoke the IAM/read permissions KnoxCall used to discover secrets from the cloud store
* You can archive or delete the secrets from the original cloud store
* Removing intercept rules or DB proxy routes at this point is safe
## Verification timeline API
Fetch the full probe history:
```bash theme={"dark"}
curl https://api.knoxcall.com/admin/migrations/mgr_01abc.../verification-events \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
Response:
```json theme={"dark"}
{
"migration_id": "mgr_01abc...",
"safe_to_delete": false,
"verified_at": null,
"verification_window_days": 35,
"events": [
{
"id": "vev_01...",
"secret_migration_item_id": null,
"event_type": "tls_probe",
"endpoint_url": "secretsmanager.us-east-1.amazonaws.com:443",
"outcome": "still_reachable",
"details": {},
"source": "agent",
"created_at": "2026-05-25T10:25:00Z"
},
{
"id": "vev_02...",
"secret_migration_item_id": null,
"event_type": "dns_probe",
"endpoint_url": "secretsmanager.us-east-1.amazonaws.com",
"outcome": "still_reachable",
"details": {},
"source": "agent",
"created_at": "2026-05-25T10:25:00Z"
}
]
}
```
## Troubleshooting
**DNS probe shows `fail`:**
* Cloud provider may have changed the endpoint hostname — check for provider announcements
* If using a VPC endpoint, ensure the agent has DNS visibility to the VPC endpoint resolver
**TLS probe shows `fail`:**
* Certificate rotation at the cloud provider — usually transient; check if the next probe resolves
* Network policy blocking agent outbound to the cloud store — check firewall / security group rules
**Consumer still showing old provider after routing through KnoxCall:**
* Some consumers may be long-running processes that cached the old connection — restart them
* Check for consumers not covered by the intercept rules (path prefix mismatch)
**Operator confirmation button greyed out:**
* One or more consumers have not yet been seen through the KnoxCall path
* Check the consumer list for any workloads in `pending` state and verify they're running and using the updated connection string (Bucket B) or within the intercept scope (Bucket A)
HTTPS intercept setup and cutover
Database proxy setup and cutover
Verification events and confirm endpoints
Back to overview
# Certificate Authority overview
Source: https://docs.knoxcall.com/essentials/pki/pki-overview
Run a private CA inside KnoxCall. Issue short-lived TLS certs for mTLS, internal services, and IoT — without standing up your own PKI.
# Certificate Authority overview
KnoxCall's **Certificate Authority** lets each tenant run a private PKI: a long-lived **root** signs short-lived **intermediates**, and the intermediate signs **leaf** certificates you hand out to services. You get a clean trust chain you control without standing up `step-ca`, `cfssl`, or Vault PKI yourself.
Use it for:
* **mTLS between internal services** — every service gets a leaf, every TLS handshake authenticates both ends.
* **Service identity** — short-lived certs are a stronger identity than long-lived API keys (a stolen cert dies on its own).
* **IoT / device fleets** — issue a leaf per device, scope it via roles.
* **Replacing manual certificate operations** — no more long-lived self-signed `.pem` files in `git`.
## The trust chain at a glance
```text theme={"dark"}
Root CA (10 years)
└── Intermediate CA (auto-rotated, e.g. 30 days)
├── Leaf cert api.example.com (e.g. 30 days)
├── Leaf cert worker.example.com
└── Leaf cert iot-device-7af2
```
* **Root** — long-lived (10 years by default). Signs **only** intermediates. Its private key is wrapped with your [tenant master key](/essentials/key-management/tenant-master-key), so cryptographic erasure of that master key permanently destroys the root.
* **Intermediate** — short-lived. KnoxCall's lease manager auto-rotates it before expiry. There's only ever one active intermediate per root at a time.
* **Leaf** — issued under a **role** that constrains what it can claim (allowed domains, max TTL, etc).
You install the **root** in your trust store once. Everything below it validates automatically.
## Customer-facing flow
1. **Create a root** in the admin UI under **Certificate Authority → New Root**. Pick a mount name (`corp`, `iot`, `web`) and the Subject DN fields. KnoxCall auto-creates the first intermediate.
2. **Define a role** that scopes what leaves can be issued under this root — e.g. "only `*.internal.example.com`, max TTL 7 days".
3. **Issue a leaf** with `POST /admin/pki/roots/{name}/issue/{role}`. You get back the cert PEM + private key — **the private key is shown once and never stored**.
4. **Install the root cert** in clients' trust stores (`GET /admin/pki/roots/{name}/cert` returns the PEM).
5. **Revoke** any leaf if it leaks (`POST /admin/pki/roots/{name}/revoke`). The cert lands in the [CRL](#revocation--crls).
## What's stored
| Object | Stored | Private key location |
| - | - | - |
| Root | Cert PEM + wrapped private key | KnoxCall, wrapped with tenant master key |
| Intermediate | Cert PEM + wrapped private key | KnoxCall (cloud) or shipped to self-hosted agents in the session bundle |
| Leaf | Cert PEM only | **Returned to caller once, never persisted** |
If you lose a leaf's private key, you re-issue. KnoxCall doesn't have it.
## Algorithms
* **v1**: ECDSA P-256 only (root, intermediate, and leaf).
* **Roadmap**: RSA, EdDSA. The schema's `key_algorithm` column is enum-constrained so adding new algorithms is a server change, not a data migration.
## Issuance roles
A **role** is the constraint template applied when a leaf is issued under it. The Vault equivalent is `pki/roles/`.
* `allowed_domains` — glob list of CN/SAN values, e.g. `['*.example.com', 'api.internal']`.
* `allow_subdomains` — whether `foo.example.com` is allowed when the role allows `example.com`.
* `allow_wildcards` — whether the leaf itself can be a wildcard cert.
* `max_ttl_seconds` / `default_ttl_seconds` — TTL bounds. Leaves are also capped at the **intermediate's** remaining validity, so a leaf can't outlive the intermediate that signed it.
See [Roles & issuance](/essentials/pki/roles-and-issuance) for the rule matrix.
## Issuance paths
| Path | Where SQL/signing runs | When to use |
| - | - | - |
| **Cloud** (`issued_via='cloud'`) | KnoxCall control plane signs the leaf | Default. Customers using cloud KnoxCall. |
| **Agent** (`issued_via='agent'`) | A self-hosted KnoxCall agent signs the leaf locally using the intermediate's private key in its session bundle | Self-hosted deployments where the issuance call must stay inside the customer's network. |
Both paths produce certs under the **same** root chain — clients in your trust store don't know or care which one signed.
## Revocation & CRLs
* `POST /admin/pki/roots/{name}/revoke` with a `serial_hex` flips the cert's `revoked_at` timestamp.
* `GET /admin/pki/roots/{name}/crl` returns a Certificate Revocation List your verifiers can poll.
* **v1** emits a text CRL for inspection; binary DER CRL is a follow-up. Don't depend on the byte format yet for production verifiers.
* Short TTLs (days, not years) mean revocation is mostly a "fast kill" tool — most leaves age out on their own before a CRL pull would catch them.
## Lease integration
The intermediate is rotated by the [lease manager](/essentials/databases/leases) via the `pki_intermediate` lease type. You don't run a cron — KnoxCall mints the next intermediate before the current one expires, retires the old one, and (for self-hosted) ships the new one to agents in their next session bundle.
If an intermediate ever expires without a successor, leaf issuance fails with a clear error. Inspect the `Active leases` panel on the Certificate Authority page to see when the next rotation will happen.
## Who can issue a certificate
Issuing a leaf certificate hands you its private key, so it is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The same applies to issuing an mTLS client certificate from a client's **Credentials** section (**Issue a new cert**), which mints the certificate and its private key under your client-authentication CA; registering a certificate you generated yourself by its thumbprint hands nothing back and needs neither. On the Management API (`/v1`) a credential still needs the matching permission, and a token issued to a person never carries `pki:issue`. Creating roots
and roles, revoking and downloading the CRL are unchanged.
## Next steps
* [Roles & issuance →](/essentials/pki/roles-and-issuance)
* [Tenant master key →](/essentials/key-management/tenant-master-key) — what cryptographic erasure of a tenant master key does to your CA
* [API reference →](/api-reference/overview)
# Roles and issuance
Source: https://docs.knoxcall.com/essentials/pki/roles-and-issuance
How KnoxCall constrains what each leaf cert can claim. Allowed domains, wildcards, TTLs, and the validation rules applied at issuance time.
# Roles and issuance
A **role** is the issuance template applied when you mint a leaf cert under a root. It answers: "what's a leaf allowed to claim, and for how long?" The role is what enforces the difference between a CA that hands out anything and a CA that's safe to delegate.
## What a role controls
| Field | What it does |
| - | - |
| `allowed_domains` | Glob list of CN / SAN values the leaf may claim. `['*.example.com', 'api.internal']` — only these. |
| `allow_subdomains` | If `true`, `*.allowed-domain` (e.g. `foo.bar.example.com` under `*.example.com`) is permitted. If `false`, only exact matches. |
| `allow_wildcards` | If `true`, the leaf itself can be a wildcard cert (`*.example.com`). If `false`, only concrete hostnames. |
| `default_ttl_seconds` | Default validity when the caller doesn't specify. Default 30 days. |
| `max_ttl_seconds` | Hard ceiling per-issue. Default 90 days. |
| `key_algorithm` | Forced to `ecdsa-p256` in v1. |
The maximum effective TTL is `min(role.max_ttl, intermediate.not_after - now)`. A leaf can't outlive the intermediate that signed it.
## Creating a role
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/roles \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"role_name": "internal-services",
"allowed_domains": ["*.internal.example.com", "*.svc.cluster.local"],
"allow_subdomains": true,
"allow_wildcards": false,
"max_ttl_seconds": 604800,
"default_ttl_seconds": 86400
}'
```
This says: leaves under `internal-services` can claim any host under `*.internal.example.com` or `*.svc.cluster.local`, can't be wildcards themselves, and live at most 7 days (24h by default).
## Issuing a leaf
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/issue/internal-services \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"subject": { "common_name": "auth-svc.internal.example.com" },
"san_dns": ["auth-svc.internal.example.com", "auth-svc.svc.cluster.local"],
"ttl_seconds": 86400
}'
# Response:
# {
# "serial_hex": "...",
# "cert_pem": "-----BEGIN CERTIFICATE-----...",
# "private_key_pem": "-----BEGIN EC PRIVATE KEY-----...",
# "ca_chain_pem": "...",
# "not_after": "2026-04-27T..."
# }
```
The `private_key_pem` is **shown once**. KnoxCall doesn't keep it. Save it next to the cert in whatever your service expects (Kubernetes secret, file mount, etc) — you can't fetch it again.
The `ca_chain_pem` includes the intermediate so clients can validate without a separate fetch.
## Subject DN inheritance
Leaves inherit org-level Subject DN fields (Organization, Country, Province, Locality) from the root if you don't supply them on the issue call. CN and SAN come from the request. This keeps "issued by Acme Inc, in California" consistent across every leaf without repeating it on every call.
You can override any field per-issue if you really need to.
## Validation rules at issue time
KnoxCall rejects an issuance request when:
* The CN or any SAN doesn't match `allowed_domains` (subject to the `allow_subdomains` / `allow_wildcards` flags).
* The requested TTL exceeds `role.max_ttl_seconds`.
* The requested TTL would push `not_after` past the intermediate's expiry. (Effective ceiling is `min(role.max_ttl, intermediate.not_after - now)`.)
* The intermediate is `retired` and no successor has been minted yet — surface as a `409` so the caller can retry.
## Revocation
To revoke a leaf:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/revoke \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{ "serial_hex": "", "reason": "key_leaked" }'
```
The cert lands in the CRL (`GET /admin/pki/roots/corp/crl`). With short TTLs, most certs age out before a CRL pull catches them — revocation is a fast kill switch, not the primary expiration mechanism.
## What KnoxCall doesn't do (yet)
* **OCSP responder** — only CRLs at v1.
* **DER-encoded CRL** — text format only at v1; consumers that strictly require DER need a follow-up.
* **Non-EC algorithms** — RSA / Ed25519 are roadmap.
* **CSR-based issuance** — the keypair is generated server-side and the private key is returned to the caller. CSR support is on the list.
## Quick rules of thumb
* Pick **short** default TTLs (24–48h). Issuance is cheap; renewal is a `curl`.
* Use **separate roots** for different trust domains (`corp` vs `iot` vs `web`). They have separate root certs, so a leak in one doesn't compromise the others.
* Use **separate roles** within a root for different scopes — e.g. `read-only-services` (allow\_subdomains=false) vs `infra-services` (allow\_subdomains=true).
* The customer's trust store only ever needs the **root** PEM. Re-installing it across the fleet is the expensive operation; rotating intermediates and leaves is invisible.
## See also
* [Certificate Authority overview →](/essentials/pki/pki-overview)
* [API reference →](/api-reference/overview)
# Ephemeral Proxy
Source: https://docs.knoxcall.com/essentials/proxy/ephemeral-proxy
One-shot proxy for sensitive requests. Resolve vault tokens on the wire so raw PII and secrets do not have to flow through your application.
# Ephemeral Proxy
The **Ephemeral Proxy** is a one-shot HTTPS proxy. Unlike [Routes](/essentials/routes/what-are-routes), there is no pre-configured row — every call is its own invocation. You pass the upstream URL in a header, KnoxCall validates it, swaps any `{{ token: ... }}` references for the underlying vault values, and forwards the request.
The composition with [Vaults](/essentials/secrets/secrets-overview) is the killer feature: **store the token in your database, send the token to KnoxCall, KnoxCall sends the raw value to the upstream.** On this path the raw SSN, bank detail or API secret is never returned to your systems.
Scope of that guarantee: the proxy never hands the value back to you, but the
same credential that can detokenize on the proxy path can also read the value
outright via `GET /v1/vaults/:vault/tokens/:token`. The control is about where
values *flow*, not a ceiling on what a detokenize-capable credential can reach.
Binding a vault to allowed destinations, and refusing the plain read for
card-shaped vaults, is planned work.
## When to use it
Use Ephemeral Proxy when:
* The destination URL is dynamic — chosen per-request, not per-route.
* You want to detokenize vault tokens last-mile (PII handling, secret injection).
* You need a quick passthrough without configuring a Route, ApiKey scope, environment overrides, etc.
Use [Routes](/essentials/routes/what-are-routes) when:
* You call the same upstream repeatedly and want signing, rate limits, env overrides, and full per-request observability.
* You need IP allowlisting, mTLS, or method-specific configuration.
* You want logs grouped by route in the dashboard.
## How a request flows
```text theme={"dark"}
Your client → POST https://api.knoxcall.com/v1/proxy
X-Knox-Proxy-URL: https://api.stripe.com/v1/charges
Authorization: Bearer kc_live_...
Content-Type: application/json
{ "amount": 5000,
"currency": "usd",
"source": "{{ token: tok_01H... }}" }
KnoxCall:
1. Authenticates your OAuth access token (or legacy API key)
2. Checks `proxy:invoke` permission + your plan limit
3. Validates the upstream URL (https only, DNS hostname, SSRF-safe)
4. Scans body + query for {{ token: ... }} references
5. Looks up each token in your vaults, gates per-vault detokenize
6. Renders the body with raw values substituted in
7. Sends the rewritten request to the upstream
8. Streams the response back to you, audit-logged
Stripe sees: { "amount": 5000, "currency": "usd",
"source": "tok_visa_real_card_token" }
You see: Stripe's response, passed through unchanged.
```
The token strings, raw values, and bodies are **never** logged. Only metadata (upstream host, status code, latency, token count) lands in the audit chain and the Fleet Graph.
## Quick example
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X POST https://api.knoxcall.com/v1/proxy \
-H "Authorization: Bearer $TOKEN" \
-H "X-Knox-Proxy-URL: https://api.stripe.com/v1/charges" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "usd",
"source": "{{ token: tok_01HZX... }}"
}'
```
The `tok_01HZX...` came from a prior `POST /v1/vaults/.../tokenize` call — see the [Vaults overview](/essentials/secrets/secrets-overview).
## Template syntax
Two reference forms are supported in the JSON body and query string:
| Form | What it does |
| - | - |
| `{{ token: tok_xxx }}` | Looks up the token in your tenant's vaults and substitutes the raw value. |
| `{{ encrypted \| json: $.path }}` | Reads a value from the optional `X-Knox-Encrypted` header payload (base64-encoded JSON in v1) using JSONPath. |
| `\{{ ... }}` | Escape — renders a literal `{{ ... }}`, never resolved. Use it when the braces are meant for the upstream's own templating. |
Templates are scanned in body and query only. Headers pass through unchanged in v1 — header-injection is a separate hardening pass.
If a token isn't found, KnoxCall returns `400 token_not_found` rather than silently sending an empty value to the upstream. Anything else brace-shaped that isn't one of the forms above — `{{ secret: x }}`, `{{ token: }}`, a mis-cased `{{ TOKEN: ... }}` — is likewise refused with `400 template_error` rather than forwarded verbatim, so an unresolved template can never reach the upstream in place of a credential.
## Required and optional headers
| Header | Required | Purpose |
| - | - | - |
| `Authorization: Bearer ` | Yes | Standard KnoxCall API auth — an OAuth access token (`kc_…`) or legacy API key (`tk_…`). |
| `X-Knox-Proxy-URL` | Yes | The upstream URL. Must be `https://`, must be a DNS hostname (no IP literals), must pass SSRF checks. |
| `Content-Type` | Recommended | Defaults to `application/json` if omitted. |
| `X-Knox-Encrypted` | No | Base64-encoded JSON payload addressable via `{{ encrypted \| json: ... }}`. v1 is opaque base64-JSON; full JWE support is on the roadmap. |
| `X-Knox-Timeout-Ms` | No | Per-request upstream timeout (positive integer ≤ 30000). |
Reserved headers (`Authorization`, `Cookie`, `Host`, `Content-Length`, anything starting with `X-Knox-`) are stripped before the request reaches the upstream, and so is `X-Api-Key` when it holds a KnoxCall credential (a value beginning `tk_`, `kc_`, `kr_` or `AKE`) — that is the key you authenticated *to KnoxCall* with, and forwarding it would put a key granting your whole management API into your upstream's access logs. An `X-Api-Key` carrying your **upstream's** own key (`sk-ant-…`, and so on) is forwarded unchanged, so providers that authenticate with that header keep working. The headers KnoxCall reads itself and the headers a proxy in front of KnoxCall adds are not forwarded either: `X-KnoxCall-Key`, `X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` and the rest of the exact names in [Request headers KnoxCall does not forward](/api-reference/request-headers). Those are matched by exact name, not by prefix. Every other header you send is forwarded unchanged, including a `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair for an upstream behind Cloudflare Access.
## What you get back
KnoxCall passes the upstream response through verbatim — same status code, same headers (minus hop-by-hop), same body bytes. The one exception is a request that detokenized a card number; see [Responses are re-tokenized](#responses-are-re-tokenized) below. KnoxCall adds two headers so you can correlate:
| Response header | Meaning |
| - | - |
| `X-Knox-Request-Id` | The KnoxCall request ID for audit-log lookup. |
| `X-Knox-Destination-Status` | The upstream's status code (echoed; useful when proxies/CDNs mangle the surfaced status). |
If the upstream is unreachable, KnoxCall returns `502 upstream_error`.
### Responses are re-tokenized
If your request detokenized a token from a **`pan`** vault, KnoxCall scans the
upstream's reply for that exact card number and puts the token back before the
response reaches you. Many processors echo the card number in their confirmation
object, in a `Location` redirect or in a plain-text receipt — without this, the
number they echoed would land in your backend, your logs and your database, which
is precisely what tokenization exists to prevent.
What is replaced, and what is not:
* **Only the values this request opened**, matched exactly. A different card
number the processor happens to mention is your own data and is left alone;
this is not a general card scan of your responses.
* **Only `pan` vaults.** A value from a `generic`, `ssn` or `email` vault echoed
back is untouched.
* **However the processor spelled it.** `4111111111111111`,
`4111 1111 1111 1111`, `4111-1111-1111-1111` and the form-encoded
`4111+1111+1111+1111` / `4111%201111%201111%201111` all match.
* **Body and headers.** JSON fields (at any depth, including object keys and
values sent as bare JSON numbers), form-encoded values, text bodies, and header
values such as `Location`. A header value is *replaced*, never stripped, so a
3-D Secure redirect keeps working.
Two consequences to plan for:
* **A rewritten body is returned uncompressed.** KnoxCall sends
`Accept-Encoding: identity` upstream on any request that detokenizes a `pan`
token, because a compressed reply cannot be scanned. If the upstream ignores
that and compresses anyway, the response is withheld with
`502 response_not_retokenizable` rather than forwarded unscanned. The same
answer is given for a binary body.
* **Streaming is refused for these requests.** A card number can straddle any
chunk boundary the upstream picks, so a streamed reply cannot be re-tokenized
honestly. A request that both detokenizes a `pan` token and asks for streaming
(`Accept: text/event-stream`, or `"stream": true` in the body) is answered
`409 pan_streaming_unsupported`. Send the card request without streaming.
Every response KnoxCall re-tokenizes writes a `vault.retokenize` audit entry
naming the vault and how many occurrences were replaced — never the number and
never the token. A non-zero count is worth acting on: it tells you your processor
is echoing card data back.
### When a referenced token cannot be resolved
A `{{ token: … }}` that KnoxCall cannot exchange for its value **stops the
request before the upstream is called** — nothing is sent with the hole
unfilled. The status says whether retrying can help:
| Status | Code | What happened |
| - | - | - |
| `400` | `token_not_found` | No token of that string exists in this tenant and mode. |
| `400` | `token_expired` | The token exists and has passed its TTL. |
| `400` | `validation_error` | A token *id* (a UUID) was passed where the token string belongs. |
| `402` | `plan_limit` | The plan's vault-operation allowance is used up. |
| `403` | `vault_destination_refused` | The vault will not release the value to this upstream host — it is not on the vault's `allowed_destinations`. |
| `409` | `vault_disabled` | The vault holding the token has its **enabled** switch off. |
| `409` | `pan_streaming_unsupported` | The request detokenizes a `pan` token *and* asks for a streamed response. A stream cannot be re-tokenized — see [Responses are re-tokenized](#responses-are-re-tokenized). |
| `502` | `response_not_retokenizable` | The upstream answered a request that detokenized a `pan` token with a body KnoxCall could not scan (compressed, or binary). The reply was withheld rather than forwarded unscanned. **Do not blindly retry** — the upstream call already happened. |
| `500` | `internal_error` | A KnoxCall fault — a key, a KMS or the database. The only one worth retrying. |
Every `4xx` above is final: the identical request is refused identically. The
Routes surface answers the same conditions with its own `relay_*` codes — see
[why a Route field action refused your request](/essentials/vaults/overview#why-a-route-field-action-refused-your-request).
### Raw card numbers are refused on egress
KnoxCall **refuses to forward a request whose body contains a payment card
number in the clear**, unless you have explicitly named the destination as one
that may receive raw card data. This applies in **both** body modes, including
`X-Knox-Proxy-Mode: transparent`, whose bytes are scanned as text.
```json theme={"dark"}
HTTP/1.1 403 Forbidden
{
"error": {
"type": "raw_pan_egress_refused",
"message": "This request body contains what looks like a payment card number in the clear, and 'api.example-psp.com' is not allowed to receive raw card data. …"
}
}
```
**The two ways forward.**
1. **Tokenize the card and send the token** — the intended path. Put the number
in a vault whose `token_format` is `pan` and send
`{{ token: kcv_… }}`. KnoxCall detokenizes it only toward that vault's own
`allowed_destinations`, so the number reaches your processor and nothing
else. A value detokenized this way is never refused by this check.
2. **Allow the destination** — if a host genuinely must receive the number
itself, an owner or admin replaces the workspace's raw-card egress allowlist:
```http theme={"dark"}
PUT /admin/pan-egress/allowed-hosts
Content-Type: application/json
{ "allowed_hosts": ["api.example-psp.com", "*.example-psp.com"] }
```
Entries are bare hostnames or `*.suffix` wildcards — no scheme, port, path
or IP literal. **Adding** a host requires a fresh second factor; removing one
does not, because shutting the door is the lever you want reachable during an
incident. **On a configured Route there is a narrower option** — the route's
Security tab grants raw-card egress for that route in that environment alone,
without opening the destination to `/v1/proxy`, workflows or webhooks. See
[Routes](/essentials/routes/creating-routes#raw-card-numbers-are-refused-on-egress).
Every change is audited as `egress.pan_allowed_hosts_updated` with
the previous list, so it can be reverted from the trail alone. **The list is
empty by default**, which is why the refusal is what you see first. (There is
no dashboard screen for this yet — the API is the surface.) The matching
`GET` returns the list plus `refused_this_month`, so you can see how often the
refusal has fired without trawling the audit log.
**What counts as a card number.** 13–19 digits, optionally grouped with spaces
or hyphens, that pass the Luhn checksum **and** begin with a recognised issuer
prefix (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay). An ordinary
16-digit order or reference number is not refused: both tests have to pass, and
roughly nine in ten arbitrary 16-digit numbers fail the checksum alone.
**Where else it applies.** The same refusal runs on configured Routes, the
workflow **HTTP Request** step, the AI Gateway, and outbound webhook payloads —
so there is no second door. Each writes an `egress.raw_pan_refused` audit entry
naming the destination host and a masked sample (last four digits only); the
number itself is never logged, never stored and never echoed back to you.
## Limits and gates
| Gate | Behaviour |
| - | - |
| **Plan** | Requires the **Starter** plan or above. `402 plan_limit` if disabled. |
| **Permission** | `proxy:invoke` on resource type `ephemeral_proxy`. Per-vault `vault:detokenize` is also evaluated for each referenced token. |
| **Tokens per request** | Maximum **20** `{{ token: ... }}` references per request. `400 validation_error` past the cap. |
| **Upstream URL** | HTTPS only, DNS hostname only (no IP literals), private/loopback ranges blocked, dangerous metadata endpoints blocked. |
| **Timeout** | Default 25s, max 30s. |
## Audit and observability
Every invocation writes:
* An `api_requests` row with `proxy_mode = 'ephemeral'`, status code, latency, upstream host, token count, and source IP. **The body, headers, and token values are not stored.**
* An `audit_log` entry tagged `ephemeral_proxy.invoke`, carrying
`retokenized_occurrences` — how many echoed card numbers were replaced with
their token on the way back (`0` on every request that opened none).
* One `vault.retokenize` entry per vault whose card number the upstream echoed,
with the occurrence count. The vault's id is recorded; the number and the token
never are.
* A Fleet Graph signal carrying `(method, upstream_host, status_code, latency_ms)` — never the body, never the token strings, never the customer's path.
You can query the audit chain via `GET /v1/audit-logs?action=ephemeral_proxy.invoke` ([Audit Logs](/api-reference/audit-logs/list)).
## Comparison: Routes vs Ephemeral Proxy
| | Routes | Ephemeral Proxy |
| - | - | - |
| Lifecycle | Persistent row, configured once, called many times. | One-shot — no row, no config. |
| Upstream URL | Set per route, can be overridden per environment. | Set per request via `X-Knox-Proxy-URL`. |
| Discovery in dashboard | Listed under **Routes**, full per-route metrics. | Not listed — discoverable via audit logs and Fleet Graph. |
| Best for | Repeat traffic to a known partner API. | Tokenization passthrough, dynamic destinations, lightweight use cases. |
| Headers, signing, IP allowlist, mTLS | All configurable. | Headers passthrough only; SSRF + plan gating only. |
| Token resolution | Configured per-route via injection rules. | Inline `{{ token: ... }}` syntax in request body / query. |
## Next steps
* See the [Ephemeral Proxy API reference](/api-reference/ephemeral-proxy/invoke) for the complete request/response schema.
* See [Vaults](/essentials/secrets/secrets-overview) to create the tokens you'll reference.
* See [Routes](/essentials/routes/what-are-routes) when you need a persistent, observable proxy instead.
# Rate Limiting overview
Source: https://docs.knoxcall.com/essentials/rate-limiting/overview
Burst, sustained, and daily quota limits per route, per client, and per tenant. Enforced at the proxy edge so a runaway script can't blow your upstream quota.
# Rate Limiting overview
KnoxCall enforces rate limits at the **proxy edge** — before the request ever reaches your upstream API. A misbehaving client hits a `429` from KnoxCall, your upstream sees nothing, your daily Stripe / OpenAI / Anthropic quota stays intact.
Three independent limit tiers run on every request. Any one of them tripping returns 429:
* **Per-route** — a single route's burst + sustained + daily ceiling.
* **Per-client** — fairness within a route, so one customer can't starve the others.
* **Per-tenant** — a global ceiling for your tenant, useful for trial limits or as a hard kill-switch.
A request that's allowed by all three tiers proceeds; the 429 response — when one is returned — comes back with the precise window that failed and a `Retry-After` header.
## Why use it
| Problem | Without rate limiting | With KnoxCall |
| - | - | - |
| Runaway script blows a daily Stripe quota at 2am | Your team finds out from Stripe at 9am | Hits 429 at burst threshold; never reaches Stripe |
| One noisy customer monopolises the API | Legitimate traffic queues behind their bursts | Per-client limits enforce fairness without per-customer code |
| Free-tier abuse | Manual ban + chase | Tenant-level cap; abuse self-throttles |
| Upstream provider's own rate limits trigger | Cascading 429s, retries multiply load | Upstream quota headers ([Anthropic](https://docs.anthropic.com/), [OpenAI](https://platform.openai.com/)) parsed and respected automatically |
## The three tiers
Each tier shares the same shape: a configurable `requests` count over a sliding window, plus an optional `burst` allowance for short spikes.
### Per-route
Configured per route. Typical settings:
| Window | Use for |
| - | - |
| **Burst (RPS)** | Spike protection. Sliding 1-second window. |
| **Sustained (RPM / RPS)** | Steady-state ceiling. 1-minute or 5-minute window. |
| **Daily quota** | Cost control. Resets at UTC midnight or your configured tz. |
A route's burst is usually 2-3× sustained. KnoxCall uses a sliding window, not a fixed bucket, so traffic doesn't pile up at window boundaries.
### Per-client
When a route is fronted by [Clients](/essentials/clients/what-are-clients) (one client = one of your customers / SDKs / integrations), you can set per-client RPS and per-day caps. KnoxCall identifies the client from its API key on the inbound request.
This is the lever for fairness: 100 RPS total on a route, with each client capped at 10 RPS, means no single noisy client can starve the others.
### Per-tenant
A tenant-wide ceiling across all routes, set by your plan. Unlike the route and client
limits it is a **token bucket**, not a sliding window: it refills continuously at your
plan's per-minute rate (Free 100, Starter 1,000, Pro 5,000, Enterprise 10,000), and holds
**ten seconds** of that rate (17 / 167 / 834 / 1,667) — the most you can send at once.
Sent evenly, your whole per-minute rate is admitted; a burst larger than the bucket is
refused with `X-RateLimit-Layer: tenant`, and `X-RateLimit-Burst` on that 429 states the
bucket. See [Rate limiting](/advanced/rate-limiting) for the headers.
It is useful for:
* **Trial limits** — cap free-tier tenants at N requests/day across all routes.
* **Hard kill-switch** — set a low ceiling during incident response to limit blast radius.
* **Predictable cost** — your monthly egress / upstream-API cost is bounded by the tenant ceiling.
## Sliding window semantics
KnoxCall uses **Redis sorted-set sliding windows**, not fixed buckets. Every request stamps the current timestamp; counting "how many requests in the last 60 seconds" walks the sorted set and counts members in the window.
Practical effect: a client sending 60 requests in the first second of a minute, then waiting, sees the limit relax linearly over the next 60 seconds. With a fixed-bucket implementation they'd be blocked for the rest of that minute then free to send another 60 immediately at the boundary — the classic burst-at-window-boundary attack.
If Redis is **disabled or not configured** (e.g. local dev), KnoxCall falls back to an **in-memory** counter per process — less accurate for a multi-replica deployment, but limits are still enforced. If a *configured* Redis becomes unreachable, proxy rate limits **fail open** instead: the request is allowed with full remaining quota so a control-plane Redis outage doesn't block tenant traffic. (Auth-sensitive limits fail *closed* — denying the request — rather than falling back.)
## 429 response shape
When any tier trips:
```http theme={"dark"}
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2026-05-04T12:00:23Z
X-RateLimit-Layer: route
Content-Type: application/json
{
"error": "Too Many Requests",
"message": "Route rate limit exceeded. Try again in 23 seconds.",
"limit": 100,
"remaining": 0,
"reset": "2026-05-04T12:00:23Z",
"layer": "route"
}
```
Your client SDK reads `Retry-After` and waits. The `X-RateLimit-Layer` header tells you *which* limit tripped (`"route"` or `"tenant"`) — useful for debugging "is this me being noisy or a tenant-wide ceiling?".
## Upstream quota header passthrough
KnoxCall parses upstream rate-limit signals and surfaces them in your **API Logs** automatically:
| Provider | Headers parsed |
| - | - |
| Anthropic | `anthropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-{limit,remaining,reset}` |
| OpenAI | `x-ratelimit-{limit,remaining,reset}-{requests,tokens}` |
| Stripe | `Stripe-Should-Retry`, `Retry-After` |
| GitHub | `X-RateLimit-{Limit,Remaining,Reset}` |
These power [smart\_ai alerts](/essentials/inbound-webhooks/overview) — KnoxCall fires you a Slack ping when an upstream quota crosses 80% so you can react before it hits 100%.
## Quick start (UI)
1. Go to **Routes → \[your route] → Rate Limiting** tab.
2. Set burst (e.g. `200 RPS`), sustained (`100 RPS`), and daily quota (`1M / day`).
3. If the route uses Clients, set per-client caps in the Clients tab.
4. For tenant-wide ceilings, **Settings → Billing → Quotas**.
## Quick start (API)
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# Set rate limits on an existing route (environment config PATCH)
curl -X PATCH https://api.knoxcall.com/v1/routes/stripe-charges \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rate_limit_enabled": true,
"rate_limit_requests": 100,
"rate_limit_window_sec": 1,
"rate_limit_burst": 200
}'
```
## Plan limits
| Tier | Per-route limits | Per-client limits | Per-tenant ceiling |
| - | - | - | - |
| Free | basic burst | not available | 100 calls/day |
| Starter | burst + sustained | not available | 10K calls/month |
| Pro | full (burst + sustained + daily) | available | 1M calls/month |
| Enterprise | full | available | unlimited |
Rate limiting is enabled on Pro and above. Free / Starter get the per-tenant ceiling automatically as part of the plan; per-route customisation unlocks at Pro.
## Next steps
* [Routes overview →](/essentials/routes/what-are-routes)
* [Clients overview →](/essentials/clients/what-are-clients) — for per-client fairness
* [Audit Logs →](/essentials/audit-logs/overview) — every 429 lands here
# Route Configuration Guide
Source: https://docs.knoxcall.com/essentials/routes/creating-routes
Configure routes with headers, methods, environments, and rate limiting. Intermediate guide.
# Route Configuration Guide
Learn how to configure routes with custom headers, method filtering, and environment-specific settings.
## HTTP Methods
Control which HTTP methods are allowed:
### Allow All Methods
By default, all methods (GET, POST, PUT, PATCH, DELETE) are allowed.
### Restrict Methods
1. Edit your route
2. Under **Allowed Methods**, uncheck methods you want to block
3. Example: Only allow GET and POST
**Use case:** Prevent clients from deleting data (block DELETE)
## Header Injection
Add custom headers to requests sent to your backend:
### Example: Add API Key
```json theme={"dark"}
{
"X-API-Key": "your-backend-api-key",
"X-Source": "knoxcall"
}
```
### Example: Add Authentication
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_api_key}}"
}
```
*Use `{{secret:secret_name}}` to inject secrets*
## Body Injection
Add data to the request body:
### Example: Add Tenant ID
```json theme={"dark"}
{
"tenant_id": "12345",
"source": "knoxcall"
}
```
**Note:** Only works with POST, PUT, PATCH requests
## Raw card numbers are refused on egress
A request whose body carries a payment card number **in the clear** is refused
with `403 raw_pan_egress_refused` and is **not forwarded** — unless the route's
destination host has been allowed for raw card data, either **on this route**
or **for the whole workspace**.
The check runs after header injection, body injection and any Relay field
actions, on the exact bytes that would go on the wire — so a card number that
arrives in the request, one that a `{{secret:…}}` placeholder injects, and one a
field action produces are all seen. It covers every transport a route can use:
direct, mTLS, VPN and static-egress.
**One value is exempt, and only one:** a card that a Relay `detokenize` action
just resolved from a `pan` vault. That vault's own `allowed_destinations` was
already checked against this route's host *before* anything was decrypted, so
the card path a route is built for keeps working.
**If you see this refusal**, you have three ways forward, narrowest first.
1. **Tokenize the number** into a `pan` vault and let a Relay `detokenize`
action put it back on the way out. This is the intended path and the only
one where the clear number never exists outside the hop that needs it.
2. **Allow it on this route alone.** Open the route, go to **Security**, and
turn off **Enable security defaults** to reveal the individual protections.
Then turn off *Refuse raw card numbers* — the switches in that card all read
the same way round, on meaning protected, so allowing a card number is
switching a protection **off**. The grant covers **this route in this
environment** and nothing else — not your other routes, not workflow steps,
not webhooks, not `/v1/proxy`. Switching the protection off allows the route's
current destination, labelled as such; **Add another destination** beneath it takes further
hostnames or `*.suffix` wildcards for the exceptions (a per-method target, a processor with
a host family).
An owner or admin, a fresh second factor to add a host (never to remove
one), audited as `egress.route_pan_allowed_hosts_updated`.
3. **Allow it for the whole workspace** with
`PUT /admin/pan-egress/allowed-hosts` (see
[the ephemeral proxy's section on this refusal](/essentials/proxy/ephemeral-proxy#raw-card-numbers-are-refused-on-egress)
for the request shape). This opens the destination on *every* egress path at
once, so prefer option 2 when only one route needs it. Audited as
`egress.pan_allowed_hosts_updated`.
Both allowlists are empty by default, and they are a **union** — a route grant
can only add a destination, never take one away that the workspace already
allows.
The route grant records the **hostname**, not a simple on/off. If you later
point the route at a different target, the grant no longer covers it and card
numbers are refused again — deliberately, so that consent you gave for one
destination cannot follow the route somewhere you never approved. The security
defaults card says so when it happens — and says it whether or not the
individual protections are showing — and the grant is not settable through the
`/v1` API: an API key that can proxy must not be able to authorise itself a
destination for raw card numbers.
**Self-hosted data planes: the route grant does not apply in `production` yet.**
A self-hosted node serves production traffic from the bundle the control plane
syncs to it, and that bundle does not carry the per-route grant. Non-production
environments on the same node read the database directly and DO honour it, so
the grant will appear to work in your test environment and then stop at
production — which is the opposite of what you would expect, and the reason this
warning exists.
It always fails **closed**: a route whose grant is not seen refuses the card
number, it never forwards one you did not allow. Until the bundle carries the
column, use the workspace-wide allowlist for production on self-hosted. Cloud
deployments are unaffected.
Each refusal writes an `egress.raw_pan_refused` audit entry with the destination
host and a masked sample; the number is never logged or stored.
Detection is 13–19 digits (spaces and hyphens allowed) that pass the Luhn
checksum **and** carry a recognised issuer prefix, so an ordinary 16-digit order
number is not affected.
## Moving a route that decrypts or detokenizes
If a route carries a standing **`decrypt`** or **`detokenize`** field action
(`POST /v1/routes/{routeId}/actions`), changing **where it sends** is treated
like revealing the values it opens.
A `decrypt` action opens `kc:` ciphertext and a `detokenize` action turns a
vault token back into the card number or SSN behind it, on every call through
the route. On the **request** side the opened value is sent to the route's
upstream; on the **response** side the upstream's own values are opened and
returned to the caller. Either way, whoever chooses the upstream chooses who
reads opened values, so giving such a route a **new destination** needs the same
authority as attaching the action:
* **In the dashboard:** an **Owner or Admin**, a fresh second factor (a security
key, passkey or authenticator code — never an emailed code), and an optional
reason, recorded in the audit log as a `plaintext.reveal` entry
(`surface: route.destination_change`) **before** the change is saved. An
Editor sees why on the route page and is refused, whether or not they have
**Reveal secrets**. You are asked when you **Save**, never while you edit, and
once per Save however much it changes: the page sends the Target URL, the
configuration, the allowed methods and the **Serve traffic** switch together.
Cancelling the verification saves nothing. Pushing a Target URL to every
environment asks once for all of them, and a double-click on any of the
page's commands sends the change once and asks once.
* **On the API (`/v1`):** a token issued to a **person** (`knoxcall login`, a
device or authorization-code grant) is refused; a **machine** credential must
hold the action's own permission — `transit:decrypt` on its key, or
`vaults:detokenize` on its vault. The change is recorded as
`route.destination_change` before it is written.
* **The onboarding assistant** cannot do it; it points you at the route page.
"A new destination" means a URL the route does not already store: a new base
URL, a new per-method target URL (active or not), or a new environment carrying
a URL the route has never had — including one a collection's defaults would
provision. It also means **starting to send to a URL the route only stores**:
turning on a disabled environment, turning on method-specific configs (so their
target URLs go live), allowing a method that reaches a base URL every allowed
method used to bypass, copying a URL that sits idle into a base URL, or
duplicating an environment whose URL sits idle into one that serves traffic. A URL
counts as one the route **already sends to** only when a `GET`, `POST`, `PUT`,
`PATCH` or `DELETE` request reaches it today; a URL that only another method
reaches (a route restricted to `HEAD`, or to a method name no client sends) is
treated as idle. Because two saves made at the same moment could each look
harmless, a change is judged as if the environment's switches (enabled, the
method restriction, method-specific configs) were all open: on an environment
that holds an idle URL, a save that would send to it once those switches open
— or placing an idle URL into a paused environment — asks for the verification
too. Re-saving
a URL the route already uses, removing one, turning an environment off,
duplicating an environment the route already sends to, and renaming an
environment need nothing extra.
Routes with no such action — and routes whose actions only `encrypt` or
`tokenize` — are unaffected.
## Path Handling
The request path is **appended verbatim** to the route's target base URL. KnoxCall does not rewrite, strip, or remove any path prefix.
For example, if your target base URL is `https://api.example.com` and a client requests `/api/v1/users/123`, the request is forwarded to:
```text theme={"dark"}
https://api.example.com/api/v1/users/123
```
If the target base URL already includes a path (for example `https://api.example.com/backend`), the client path is appended to it (`https://api.example.com/backend/api/v1/users/123`). Query parameters from the incoming request are preserved.
A target URL must point at a third-party upstream. Private, loopback and cloud-metadata addresses are refused, and so is any KnoxCall host (`api.knoxcall.com`, `hooks.knoxcall.com`, another workspace's `*.knoxcall.com` address, or your own) — KnoxCall never sends your route's traffic back to itself. To call one of your routes from a workflow, use the HTTP Request node's **Route** mode instead of typing the route's URL.
## Environment Configuration
Set different target URLs per environment:
1. Edit route
2. Scroll to **Environment Configuration**
3. For each environment:
* **Development**: `http://localhost:3000`
* **Staging**: `https://staging-api.example.com`
* **Production**: `https://api.example.com`
Clients specify environment via header:
```http theme={"dark"}
x-knoxcall-environment: production
```
## Rate Limiting
Limit requests per client:
1. Enable **Rate Limiting**
2. Set limits:
* **Requests**: 100
* **Window**: 60 (seconds)
* **Burst**: 120
This allows 100 requests per minute, with bursts up to 120.
## Timeout Settings
Default timeout is 30 seconds. Contact support to adjust.
## Testing Configuration
After making changes:
1. Click **Test Route**
2. Select environment
3. Enter sample request
4. View response
## Need More Help?
* See the Quick Start Guide for basics
* Learn about secrets for secure credential injection
* Check troubleshooting guides for common issues
***
* **Level**: intermediate
* **Time**: 10 minutes
`routes`, `configuration`, `intermediate`
# Route-aware interception
Source: https://docs.knoxcall.com/essentials/routes/route-aware-interception
Send an untouched third-party SDK's traffic through the Route that covers it — no base-URL swap, no per-SDK wiring. Preview.
# Route-aware interception
**Preview.** Turn a Route's **Intercept** toggle on, install the KnoxCall SDK's interceptor once, and every call your code — or any third-party SDK inside it — makes to that Route's upstream host goes through the Route. The Route injects the stored credential; the provider key never enters your process. Turn the toggle off and the traffic goes back to where it went before, on the next refresh, with no code change.
```ts theme={"dark"}
const knox = new KnoxCall(); // credentials from KNOXCALL_* env
const stop = knox.wrap.intercept(); // route discovery is on by default
await stop.ready; // first manifest loaded
const hubspot = new Client({ accessToken: "placeholder" }); // an untouched SDK
await hubspot.crm.contacts.basicApi.getPage(); // via the Route that covers api.hubapi.com
```
The same feature exists in every SDK — [Node](/sdks/node#route-aware-interception-preview), [Python](/sdks/python#route-aware-interception-preview), [Go](/sdks/go#route-aware-interception-preview), [PHP](/sdks/php#route-aware-interception-preview), [Ruby](/sdks/ruby#route-aware-interception-preview) — and in the [Go agent's intercept mode](/infrastructure/go-agent) for code you cannot change at all. All of them read one server answer, so a Route you toggle on in the dashboard takes effect for all of them.
## How it works
1. **You opt a Route in.** `intercept_enabled` is a per-environment setting on the Route (the **Intercept** toggle on each environment card of the Route page, or `intercept_enabled` on the [create](/api-reference/routes/create), [update](/api-reference/routes/update) and [environment](/api-reference/routes/environments) endpoints). It is off by default. Only one intercept-enabled Route may cover a given upstream host and base path in an environment — turning on a second is refused with `409 intercept_route_conflict`, because two Routes claiming the same traffic would be resolved by a tie-break nobody chose.
2. **The SDK polls the manifest.** `GET /v1/wrap/intercept-manifest` lists, for one environment, every intercept-enabled Route's upstream `host`, `base_path` and `slug`. The SDK refreshes it every 60 seconds (jittered), and immediately when KnoxCall refuses a route-mode request — a `401`, or the `404 route_not_found` an authenticated credential gets for a Route that no longer exists — so a Route created, enabled or disabled later is picked up without a restart. A `404` of type `environment_not_configured` or `environment_disabled` is surfaced as-is: a refresh cannot fix an environment. Your provider's own 404s are never mistaken for either — they carry `X-Knox-Upstream-Status`.
3. **Each outbound request is decided in a fixed order.** The first matching rule wins:
| # | Condition | Outcome |
| - | - | - |
| 0 | `KNOXCALL_INTERCEPT=off` in the environment | direct — the kill switch, per process, no deploy |
| 1 | Not `http(s)`, or the URL does not parse | direct |
| 2 | The SDK's own KnoxCall hosts, or any host under `knoxcall.com` | direct (anti-recursion) |
| 3 | A route-around rule matches (the built-in raw-card defaults, plus yours) | direct |
| 4 | You asked for `requireContext` and this call is outside `routed()` | direct |
| 5 | A manifest entry covers the host **and** the path (longest `base_path` wins) | **route mode** — through that Route |
| 6 | The host is in the `hosts` list you gave `intercept()` | **ephemeral mode** — through the ephemeral proxy |
| 7 | otherwise | direct — untouched |
Rule 5 before rule 6 is the whole feature: a host you listed is served by the ephemeral proxy until a Route covers it, upgrades to the Route the moment one does, and downgrades back when the Route is disabled.
## Route mode
In route mode the SDK sends the request to your tenant's data plane with `x-knoxcall-route: `, the path rebased under the Route's `base_path` (a Route targeting `https://api.hubapi.com/crm/v3` receives `/objects/contacts` for a request to `/crm/v3/objects/contacts`), and the query string intact. The request's own `Authorization` header is dropped before it leaves — the Route injects the stored secret, so a placeholder token in the third-party SDK is exactly that. Everything a Route does applies: injected headers, per-environment overrides, rate limits, client requirements, logs and analytics. The API Log marks each call the SDK rerouted as **SDK intercept** (a direct `call()` shows as **Direct**), so you can see exactly which traffic the interceptor is carrying.
A request under a covered host but **not** under any covered base path is not rerouted; it falls to rule 6 (ephemeral if the host is listed, direct otherwise) and fires `onUnmatchedPath` once per host and path prefix so you can see it.
## Ephemeral mode
A host you list in `hosts` that no Route covers goes through the [ephemeral proxy](/essentials/ephemeral-proxy/what-is-the-ephemeral-proxy) in transit mode: the request's own credential is carried on a KnoxCall header and the SDK's KnoxCall token authenticates the hop. Per-host options put a host in escrow mode instead (`credential: { secret: "" }`), or let transit go direct when KnoxCall is unreachable (`unavailable: "direct"`).
## When KnoxCall is unreachable
Route mode and escrow fail closed: there is no credential in your process to go direct with, so the SDK surfaces the connection error. Transit is the one mode that can opt out, per host, with `unavailable: "direct"`; it fires `onFallback` when it does.
## What it is, and is not
* **It is a convenience, not a security boundary.** Each SDK patches the process-wide HTTP seam its language has (global `fetch` and `node:http` in Node; `httpx` and `urllib3` in Python; `http.DefaultTransport` in Go; `Net::HTTP` in Ruby). PHP has no process-wide seam, so the SDK offers a route-aware PSR-18 client and a Guzzle middleware instead. Traffic that does not pass through that seam is not reached — each SDK page states exactly what is and is not covered.
* **The custody path is route mode.** Ephemeral transit carries your own credential through KnoxCall; escrow and route mode keep it out of your process entirely.
* **The manifest is a read.** It needs `route:read` on the SDK's credential, the same permission as listing routes; it is not permission to escrow, mint or spend anything.
* **One kill switch, no deploy.** `KNOXCALL_INTERCEPT=off` makes every interceptor and route-aware transport pass everything through, per process.
## Which stacks are reached
| Language | Installed by | Reached | Not reached |
| - | - | - | - |
| Node | `knox.wrap.intercept()` | global `fetch` (undici), `node:http` / `https` — axios, node-fetch, got, cross-fetch, OpenAPI-generated clients, `@hubspot/api-client` | a `fetch` or `request` reference captured before install; a custom undici `Dispatcher` |
| Python | `knox.wrap.intercept(stacks=["httpx", "urllib3"])`; on the async client also `stacks=["aiohttp"]` (opt-in) | `httpx` clients on the default transport; `requests`, `botocore` and OpenAPI-generated clients (urllib3); with the opt-in, any `aiohttp.ClientSession` — aiobotocore, slack\_sdk's async client, azure-core's aiohttp transport | an injected custom transport; raw `http.client`; `pycurl`; a WebSocket handshake (`ws_connect` goes direct) |
| Go | `client.Wrap.Intercept(ctx)` | `http.DefaultClient` and any `*http.Client` with a nil `Transport` | a client built with its own `Transport` — hand it `client.Wrap.RoundTripper(knoxcall.WithRoutes())` |
| PHP | `$knox->wrap->httpClient(['routes' => 'auto'])`, `$knox->wrap->guzzleMiddleware()` | any SDK that accepts a PSR-18 client or a Guzzle handler stack | an SDK that builds its own curl handle — point it at a gateway URL |
| Ruby | `knox.wrap.intercept!` (opt-in, experimental) | Faraday's default adapter, `rest-client`, `httparty`, raw `Net::HTTP` | Typhoeus, Curb, `http.rb` |
Every SDK also offers the same decisions on an explicit transport you inject yourself (`wrap.fetch({ routes: "auto" })`, `wrap.transport(routes="auto")`, `Wrap.RoundTripper(WithRoutes())`, `wrap->httpClient(['routes' => 'auto'])`, `wrap.faraday_connection(routes: :auto)`), for code that would rather not patch process globals.
## Uncovered egress
The interceptor already sees every outbound request the process makes, so it can also tell you which ones it **did not** intercept: calls that carried a credential to a host no Route covers, or to a host whose Route has interception switched off. Those are reported to KnoxCall and shown on **Proxy → Uncovered egress** in the dashboard, with one action per row — create a Route, or turn interception on for the one that already matches.
**What is sent.** Per host, the first path segment (`/` or `/`), the HTTP method, the *name* of the header the credential travelled in (`authorization`, `x-api-key`, …) and how many calls, with first- and last-seen timestamps. Nothing else: no credential values, no full paths, no query strings, no request or response bodies, no client addresses. The server refuses an entry that carries anything beyond that shape, and the header name must be on a fixed allowlist of credential headers — so a value cannot be reported even by mistake. Some APIs put a key in the path itself (a Telegram bot token is `/bot:/…`), so a first path segment that looks like a credential — a known key prefix, a token, anything unusually long — is stored as `/` instead: the host is still listed, the value is not kept.
**On by default.** Every `intercept()` install reports unless told not to. Switch it off per install with `observe_uncovered: false`, or per process with `KNOXCALL_OBSERVE_UNCOVERED=off`; either stops the reporter entirely. `KNOXCALL_INTERCEPT=off` stops it too, since there is no interceptor to observe with.
**Retention.** Observations are kept per host and day for **30 days**, then deleted. A workspace may add at most 5,000 distinct (host, path segment, method, header name) combinations per day; beyond that the SDK's report is accepted and the excess is dropped. The endpoint is `POST /v1/wrap/egress-observations` and needs `route:read`, the same permission as the manifest.
## Hooks
`onReroute` (before a KnoxCall send: host, URL, mode, slug), `onRefresh` (after a manifest change: added and removed hosts), `onManifestError`, `onUnmatchedPath`, `onRefused` (a route-mode refusal that triggered a refresh, and what the re-decision was), `onFallback` (a transit request went direct). Names follow each language's casing.
## See also
* [What are Routes?](/essentials/routes/what-are-routes) and [Creating routes](/essentials/routes/creating-routes)
* [Route environments](/api-reference/routes/environments) — `intercept_enabled` per environment
* [Go agent](/infrastructure/go-agent) — the same manifest, for processes whose code you cannot change
# Common Route Patterns
Source: https://docs.knoxcall.com/essentials/routes/route-examples
Real-world route examples and patterns. Learn from production-ready configurations for webhooks, APIs, payments, and more.
# Common Route Patterns
Production-ready examples of common route configurations. Copy, adapt, and deploy.
Client requests below lead with the KnoxCall SDK — `const client = new KnoxCall()` picks up credentials from [`knoxcall login`](/api-reference/authentication) or `KNOXCALL_CLIENT_ID` / `KNOXCALL_CLIENT_SECRET`. In raw curl examples, `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)); legacy API keys still work by sending `-H "x-knoxcall-key: tk_live_..."` instead of the `Authorization` header.
***
## Payment APIs
### Stripe Payment Processing
**Use case:** Process payments securely without exposing Stripe API key
**Configuration:**
```json theme={"dark"}
Route: stripe-payments
Target: https://api.stripe.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}",
"Content-Type": "application/json",
"Stripe-Version": "2023-10-16"
}
```
**Environment overrides:**
```text theme={"dark"}
Production:
Secret: stripe_prod_key (sk_live_...)
Staging:
Secret: stripe_staging_key (sk_test_...)
```
**Client request (SDK):**
```typescript theme={"dark"}
const resp = await client.call("stripe-payments", {
method: "POST",
path: "/v1/charges",
body: { amount: 1000, currency: "usd", source: "tok_visa" },
});
```
**Client request (curl):**
```bash theme={"dark"}
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: stripe-payments" \
-d "amount=1000" \
-d "currency=usd" \
-d "source=tok_visa"
```
**Why this works:**
* Client never sees Stripe key
* Environment-specific keys (test/live)
* Full Stripe API access through proxy
***
### PayPal Payments
**Use case:** PayPal checkout integration
**Configuration:**
```json theme={"dark"}
Route: paypal-checkout
Target: https://api.paypal.com
Methods: POST, GET
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:paypal_access_token}}",
"Content-Type": "application/json"
}
```
**OAuth2 Secret:**
```text theme={"dark"}
Type: OAuth2
Name: paypal_access_token
Token URL: https://api.paypal.com/v1/oauth2/token
Client ID: {{from PayPal dashboard}}
Client Secret: {{from PayPal dashboard}}
```
**Auto-refresh:** ✅ KnoxCall refreshes token automatically
***
## Email Services
### SendGrid Transactional Email
**Use case:** Send emails without exposing SendGrid API key
**Configuration:**
```json theme={"dark"}
Route: sendgrid-email
Target: https://api.sendgrid.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:sendgrid_api_key}}",
"Content-Type": "application/json"
}
```
**Body template:**
```json theme={"dark"}
{
"personalizations": [{
"to": [{"email": "{{var:to_email}}"}],
"subject": "{{var:subject}}"
}],
"from": {
"email": "noreply@yourdomain.com",
"name": "Your App"
},
"content": [{
"type": "text/html",
"value": "{{var:email_body}}"
}]
}
```
**Client request (SDK):**
```typescript theme={"dark"}
await client.call("sendgrid-email", {
method: "POST",
path: "/v3/mail/send?to_email=customer@example.com&subject=Welcome!&email_body=%3Ch1%3EWelcome%20to%20our%20service!%3C/h1%3E",
});
```
**Client request (curl):**
```bash theme={"dark"}
curl -X POST "https://YOUR-SUBDOMAIN.knoxcall.com/v3/mail/send?to_email=customer@example.com&subject=Welcome!&email_body=%3Ch1%3EWelcome%20to%20our%20service!%3C/h1%3E" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: sendgrid-email"
```
Dynamic placeholders in body and header templates use the `{{var:NAME}}` form, where `NAME` is read from the request's **query-string parameters**. Pass these values as query params (as shown above), not in the request body.
***
### Mailgun Email
**Use case:** Alternative email service
**Configuration:**
```json theme={"dark"}
Route: mailgun-email
Target: https://api.mailgun.net
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:mailgun_api_key}}",
"Content-Type": "application/x-www-form-urlencoded"
}
```
**Note:** Mailgun uses Basic auth (base64 encode `api:YOUR_KEY`)
***
## SMS/Communication
### Twilio SMS
**Use case:** Send SMS messages
**Configuration:**
```json theme={"dark"}
Route: twilio-sms
Target: https://api.twilio.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:twilio_auth_token}}",
"Content-Type": "application/x-www-form-urlencoded"
}
```
**Secret format:**
```text theme={"dark"}
base64(ACCOUNT_SID:AUTH_TOKEN)
```
**Client request (curl):**
```bash theme={"dark"}
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/2010-04-01/Accounts/ACCT_ID/Messages.json \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: twilio-sms" \
-d "To=+15555555555" \
-d "From=+14155552671" \
-d "Body=Hello from KnoxCall!"
```
***
## Webhooks
### Receiving Stripe Webhooks
**Use case:** Receive webhook events from Stripe
**Configuration:**
```json theme={"dark"}
Route: stripe-webhooks
Target: https://your-backend.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Content-Type": "application/json"
}
```
There is no `{{client_ip}}` template helper. The proxy strips reverse-proxy headers such as `X-Forwarded-For` from the request before forwarding it upstream, so the client IP is not passed through to your backend.
**Clients:**
```text theme={"dark"}
Type: Server
IPs: Stripe webhook IPs
- 3.18.12.63/32
- 3.130.192.231/32
- 13.235.14.237/32
- 13.235.122.149/32
... (get full list from Stripe docs)
```
**Signature validation:**
```text theme={"dark"}
Option 1: Validate in your backend
Option 2: Use KnoxCall request signing
```
**Stripe configures webhook:**
```text theme={"dark"}
Webhook URL: https://YOUR-SUBDOMAIN.knoxcall.com/webhooks/stripe
```
***
### Receiving Shopify Webhooks
**Use case:** E-commerce webhook notifications
**Configuration:**
```json theme={"dark"}
Route: shopify-webhooks
Target: https://your-backend.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Content-Type": "application/json"
}
```
There is no `{{header:...}}` template helper. Incoming request headers that aren't KnoxCall- or proxy-specific (such as `X-Shopify-Hmac-SHA256`, `X-Shopify-Topic`, and `X-Shopify-Shop-Domain`) are forwarded to your upstream automatically — you don't need to re-declare them here.
**Clients:**
```text theme={"dark"}
Allow: Shopify webhook IPs
(Or use signature validation instead of IP whitelist)
```
***
### Sending Webhooks to Partners
**Use case:** Notify partner systems of events
**Configuration:**
```json theme={"dark"}
Route: partner-acme-webhook
Target: https://webhooks.acme.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"X-Webhook-Secret": "{{secret:partner_acme_webhook_secret}}",
"Content-Type": "application/json",
"X-Source": "YourCompany"
}
```
**Body:**
```json theme={"dark"}
{
"event": "order.created",
"timestamp": "{{var:timestamp}}",
"data": {
"order_id": "12345",
"total": 99.99
},
"signature": "{{var:hmac_signature}}"
}
```
KnoxCall does not generate timestamps or HMAC signatures for you. Compute these values on the client and pass them as query-string parameters (`?timestamp=...&hmac_signature=...`); the `{{var:NAME}}` placeholders read directly from the request query string.
***
## Database / Data APIs
### PostgreSQL REST API
**Use case:** Query database through HTTP API
**Configuration:**
```json theme={"dark"}
Route: database-api
Target: https://your-db-api.com
Methods: GET, POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:database_api_token}}",
"Content-Type": "application/json"
}
```
**Example queries:**
```bash theme={"dark"}
# Read
curl https://YOUR-SUBDOMAIN.knoxcall.com/users/123 \
-H "x-knoxcall-route: database-api"
# Write
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/users \
-H "x-knoxcall-route: database-api" \
-d '{"name": "John", "email": "john@example.com"}'
```
***
### GraphQL API
**Use case:** GraphQL backend proxy
**Configuration:**
```json theme={"dark"}
Route: graphql-api
Target: https://api.example.com/graphql
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:graphql_api_token}}",
"Content-Type": "application/json"
}
```
**Client query:**
```bash theme={"dark"}
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/graphql \
-H "x-knoxcall-route: graphql-api" \
-d '{
"query": "{ users { id name email } }"
}'
```
***
## Cloud Storage
### AWS S3 Signed URLs
**Use case:** Generate pre-signed URLs for S3 uploads
**Configuration:**
```json theme={"dark"}
Route: s3-signed-urls
Target: https://your-backend.com/s3/sign
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"X-AWS-Access-Key": "{{secret:aws_access_key}}",
"X-AWS-Secret-Key": "{{secret:aws_secret_key}}"
}
```
**Your backend:**
* Generates signed URL using AWS credentials
* Returns URL to client
* Client uploads directly to S3
***
### Google Cloud Storage
**Use case:** Upload files to Google Cloud
**Configuration:**
```json theme={"dark"}
Route: gcs-upload
Target: https://storage.googleapis.com
Methods: POST, PUT
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:google_cloud_oauth_token}}",
"Content-Type": "application/octet-stream"
}
```
**OAuth2 Secret:**
```text theme={"dark"}
Type: OAuth2
Auto-refresh: Enabled
```
***
## Printing Services
### PrintNode Cloud Printing
**Use case:** Send print jobs to remote printers
**Configuration:**
```json theme={"dark"}
Route: printnode-print
Target: https://api.printnode.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:printnode_api_key}}",
"Content-Type": "application/json"
}
```
**Body:**
```json theme={"dark"}
{
"printerId": 123,
"title": "Receipt #12345",
"contentType": "pdf_uri",
"content": "https://yoursite.com/receipt.pdf",
"source": "Your POS System"
}
```
**Client request:**
```bash theme={"dark"}
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/printjobs \
-H "x-knoxcall-route: printnode-print" \
-d '{
"printerId": 123,
"title": "Receipt",
"content": "https://example.com/receipt.pdf"
}'
```
***
## Authentication / OAuth
### Auth0 Authentication
**Use case:** User authentication proxy
**Configuration:**
```json theme={"dark"}
Route: auth0-login
Target: https://YOUR-DOMAIN.auth0.com
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Content-Type": "application/json"
}
```
**Body:**
```json theme={"dark"}
{
"client_id": "{{secret:auth0_client_id}}",
"client_secret": "{{secret:auth0_client_secret}}",
"audience": "https://your-api.com",
"grant_type": "client_credentials"
}
```
***
### Google OAuth Token Exchange
**Use case:** Exchange authorization code for tokens
**Configuration:**
```json theme={"dark"}
Route: google-oauth-token
Target: https://oauth2.googleapis.com/token
Methods: POST
```
**Headers:**
```json theme={"dark"}
{
"Content-Type": "application/x-www-form-urlencoded"
}
```
**Body:**
```json theme={"dark"}
{
"client_id": "{{secret:google_client_id}}",
"client_secret": "{{secret:google_client_secret}}",
"code": "{{var:auth_code}}",
"grant_type": "authorization_code",
"redirect_uri": "https://yourapp.com/callback"
}
```
***
## Microservices Architecture
### Service-to-Service Communication
**Use case:** Multiple internal services
**Routes:**
```text theme={"dark"}
user-service → https://users.internal.com
order-service → https://orders.internal.com
payment-service → https://payments.internal.com
notification-service → https://notifications.internal.com
```
**Shared configuration:**
```json theme={"dark"}
Headers (all services):
{
"X-Internal-Token": "{{secret:internal_service_token}}",
"X-Service-Name": "api-gateway"
}
Clients:
- Type: Server
- IP: 10.0.0.0/16 (internal VPC)
```
**Benefits:**
* Centralized authentication
* Unified logging
* Rate limiting across services
* Easy service discovery
***
## Third-Party Integrations
### HubSpot CRM
**Use case:** Sync customer data
**Configuration:**
```json theme={"dark"}
Route: hubspot-crm
Target: https://api.hubapi.com
Methods: GET, POST, PATCH
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:hubspot_access_token}}",
"Content-Type": "application/json"
}
```
**OAuth2 Secret:** Auto-refreshing HubSpot token
***
### Salesforce API
**Use case:** CRM integration
**Configuration:**
```json theme={"dark"}
Route: salesforce-api
Target: https://your-instance.salesforce.com
Methods: GET, POST, PATCH, DELETE
```
**Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:salesforce_access_token}}",
"Content-Type": "application/json"
}
```
***
### Slack Webhooks
**Use case:** Send notifications to Slack
**Configuration:**
```json theme={"dark"}
Route: slack-notifications
Target: https://hooks.slack.com
Methods: POST
```
**Body:**
```json theme={"dark"}
{
"text": "{{var:message}}",
"channel": "#notifications",
"username": "Your App Bot",
"icon_emoji": ":robot_face:"
}
```
**No auth needed** (webhook URL is the secret)
***
## Testing & Development
### Mock API for Development
**Use case:** Test against mock data
**Configuration:**
```json theme={"dark"}
Route: mock-api
Target: https://jsonplaceholder.typicode.com
Methods: GET, POST, PUT, DELETE
```
**Headers:**
```json theme={"dark"}
{
"Content-Type": "application/json"
}
```
**Clients:**
```text theme={"dark"}
Type: Server
IP: 192.168.1.0/24 (office network)
Environment: development only
```
***
### Webhook Testing
**Use case:** Test webhooks with webhook.site
**Configuration:**
```json theme={"dark"}
Route: webhook-test
Target: https://webhook.site/YOUR-UNIQUE-ID
Methods: POST
```
**Clients:** Development team only
***
## Multi-Environment Patterns
### Pattern: Development → Staging → Production
**Route:** `api-proxy`
**Base (Production):**
```text theme={"dark"}
Target: https://api.production.com
Secret: prod_api_key
Clients: [prod-server-1, prod-server-2]
```
**Override (Staging):**
```text theme={"dark"}
Target: https://api.staging.com
Secret: staging_api_key
Clients: [staging-server, qa-team]
```
**Override (Development):**
```text theme={"dark"}
Target: http://localhost:3000
Secret: dev_api_key
Clients: [ALL developers]
```
**Usage:**
```bash theme={"dark"}
# Production
curl ... -H "x-knoxcall-environment: production"
# Staging
curl ... -H "x-knoxcall-environment: staging"
# Development
curl ... -H "x-knoxcall-environment: development"
```
***
## Security Patterns
### API Key Rotation Without Downtime
**Setup two routes:**
```text theme={"dark"}
Route 1: api-v1 (old key)
Route 2: api-v2 (new key)
```
**Migration process:**
1. Create api-v2 with new key
2. Update clients gradually to use api-v2
3. Monitor api-v1 traffic
4. When api-v1 traffic = 0, delete it
**Zero downtime!**
***
### Multi-Layer Security
**Route:** `sensitive-api`
**Security layers:**
1. **IP whitelist** - Only production servers
2. **API key** - KnoxCall authentication
3. **Request signing** - HMAC signatures
4. **Rate limiting** - 100 req/min
5. **Secret injection** - Backend API key
**Configuration:**
```json theme={"dark"}
Requires Clients: ✓
Require Signature: ✓
Rate Limit: 100/min
Headers: {
"Authorization": "Bearer {{secret:backend_api_key}}"
}
```
***
## Best Practices
### ✅ Do
1. **Use environment-specific secrets**
```
Production: stripe_prod_key
Staging: stripe_test_key
```
2. **Document route purpose**
```
Description: "Stripe payment processing - live transactions"
```
3. **Start simple, add complexity**
```
1. Basic route
2. Add secrets
3. Add rate limiting
4. Add signing
```
4. **Test in staging first**
```
Staging → Success → Deploy to production
```
5. **Use descriptive names**
```
✅ stripe-prod-payments
❌ route1
```
### ❌ Don't
1. **Don't hardcode secrets**
```
❌ "Authorization": "Bearer sk_live_abc123"
✅ "Authorization": "Bearer {{secret:stripe_key}}"
```
2. **Don't use production keys in dev**
```
❌ Development using prod Stripe key
✅ Development using test Stripe key
```
3. **Don't skip IP authorization for prod**
```
❌ Production route: requires_clients = false
✅ Production route: requires_clients = true
```
***
## Quick Reference
| Use Case | Target | Auth Method | Key Feature |
| - | - | - | - |
| Stripe | api.stripe.com | Bearer token | Secret injection |
| SendGrid | api.sendgrid.com | Bearer token | Email templates |
| Twilio | api.twilio.com | Basic auth | SMS/Voice |
| Webhooks | your-backend.com | IP + signature | Event notifications |
| PrintNode | api.printnode.com | Basic auth | Cloud printing |
| OAuth | oauth provider | Client credentials | Token exchange |
| Microservices | internal services | Internal token | Service mesh |
***
## Next Steps
Route basics and concepts
Step-by-step route creation
Secret injection in routes
Advanced route features
***
**Pro Tip:** Copy these examples as starting points, then customize for your specific needs. Test in staging before deploying to production!
# Testing Routes
Source: https://docs.knoxcall.com/essentials/routes/testing-routes
Use the built-in route tester to validate configuration before going live. Test headers, body, secrets, and response.
# Testing Routes
Use KnoxCall's built-in route tester to validate your configuration before sending real traffic.
## What is the Route Tester?
The **Route Tester** is a built-in tool that lets you:
* ✅ Test route configuration without writing code
* ✅ See exactly what headers/body are sent to your backend
* ✅ Verify secrets are injected correctly
* ✅ Test different HTTP methods
* ✅ Debug issues before going live
* ✅ No need for Postman or curl
## Access the Route Tester
### Option 1: From Route List
1. Navigate to **Routes**
2. Find your route
3. Click the **Test** button (🧪 icon)
### Option 2: From Route Details
1. Navigate to **Routes**
2. Click your route to open details
3. Click **Test Route** tab
4. Or click **Test** button in header
## Using the Route Tester
### Step 1: Select HTTP Method
Choose the method to test:
* GET
* POST
* PUT
* DELETE
* PATCH
* OPTIONS
* HEAD
### Step 2: Enter Path
Specify the URL path (appended to target base URL):
**Example:**
Target Base URL: `https://api.example.com`
Path: `/users/123`
**Result:** `https://api.example.com/users/123`
**Tips:**
* Include leading slash: `/api/users` ✅
* No leading slash: `api/users` ⚠️ (works but inconsistent)
* Can include query params: `/users?status=active`
### Step 3: Add Query Parameters (Optional)
Add query parameters that will be:
1. Sent to backend
2. Available in templates as `{{var:param_name}}`
**Example:**
| Key | Value |
| - | - |
| filter | active |
| sort | name |
| limit | 10 |
**Result URL:** `/users?filter=active&sort=name&limit=10`
### Step 4: Add Request Headers (Optional)
Add custom headers to include in the request:
**Example:**
| Header | Value |
| - | - |
| Content-Type | application/json |
| X-Custom-Header | test-value |
These are **in addition to** injected headers from route config.
### Step 5: Add Request Body (Optional)
For POST/PUT/PATCH requests, provide request body:
**JSON:**
```json theme={"dark"}
{
"name": "John Doe",
"email": "john@example.com",
"status": "active"
}
```
**Form Data:**
```text theme={"dark"}
name=John+Doe&email=john@example.com
```
**Raw:**
```text theme={"dark"}
Any raw text or data
```
### Step 6: Select Environment (Optional)
Choose which environment to test:
* production (default)
* staging
* development
* Custom environments
This determines which environment override config is used.
### Step 7: Click "Send Test Request"
KnoxCall will:
1. Apply route configuration
2. Inject secrets (if any)
3. Apply environment overrides (if any)
4. Apply method-specific config (if any)
5. Send request to backend
6. Return response
## Understanding Test Results
### Request Details Section
Shows exactly what was sent to your backend:
**Request URL:**
```text theme={"dark"}
https://api.example.com/users/123?filter=active
```
**Request Method:**
```text theme={"dark"}
POST
```
**Request Headers:**
```text theme={"dark"}
Authorization: Bearer sk_live_EXAMPLE_abc123xyz789...
Content-Type: application/json
X-Custom-Header: test-value
X-Forwarded-For: 192.168.1.1
```
Notice:
* `Authorization` header was injected from route config
* Secret was decrypted and value inserted
* Custom headers are included
**Request Body:**
```json theme={"dark"}
{
"name": "John Doe",
"email": "john@example.com",
"api_key": "sk_live_abc123..."
}
```
Notice:
* `api_key` was injected from body template
* Secret was decrypted
### Response Details Section
Shows what your backend returned:
**Status Code:**
```text theme={"dark"}
200 OK
```
**Response Headers:**
```text theme={"dark"}
Content-Type: application/json
X-Request-ID: req_abc123
Cache-Control: no-cache
```
**Response Body:**
```json theme={"dark"}
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"created_at": "2025-01-15T10:30:00Z"
}
```
**Response Time:**
```text theme={"dark"}
245ms
```
### Config Applied Section
Shows which configuration was used:
**Environment:**
```text theme={"dark"}
production
```
**Method Config:**
```text theme={"dark"}
POST method config (overrides global)
```
**Secrets Injected:**
```text theme={"dark"}
- stripe_api_key → sk_live_abc... (in Authorization header)
- database_url → postgres://... (in body)
```
**Rate Limit:**
```text theme={"dark"}
Not enforced in test mode
```
**Signature:**
```text theme={"dark"}
Not required in test mode
```
**Test mode bypasses some security checks:**
* Rate limiting is not enforced
* Client IP authorization is skipped
* Signatures are not required
This lets you test freely without hitting limits or needing signatures.
## Example Test Scenarios
### Scenario 1: Test Secret Injection
**Goal:** Verify Stripe API key is injected correctly
**Setup:**
1. Select method: POST
2. Path: `/charges`
3. Body:
```json theme={"dark"}
{
"amount": 1000,
"currency": "usd"
}
```
4. Send request
**Verify:**
* Request headers show: `Authorization: Bearer sk_live_...` (actual key, not template)
* Response from Stripe API (charge created)
* No errors about authentication
### Scenario 2: Test Method-Specific Config
**Goal:** Verify POST uses different headers than GET
**Test GET:**
1. Method: GET
2. Path: `/users`
3. Send request
4. **Check:** Authorization header uses `read_key`
**Test POST:**
1. Method: POST
2. Path: `/users`
3. Body: `{"name": "Test"}`
4. Send request
5. **Check:** Authorization header uses `write_key`
### Scenario 3: Test Environment Override
**Goal:** Verify staging environment uses staging URL
**Setup:**
1. Method: GET
2. Path: `/health`
3. Environment: **staging**
4. Send request
**Verify:**
* Request URL shows staging URL: `https://staging-api.example.com/health`
* Not production URL
### Scenario 4: Test Query Param Variables
**Goal:** Verify `{{var:filter}}` is replaced correctly
**Setup:**
1. Method: GET
2. Path: `/users`
3. Query params:
* `filter` = `active`
4. Send request
**Verify:**
* Request headers show: `X-Filter: active` (variable replaced)
* Not: `X-Filter: {{var:filter}}` (literal template)
### Scenario 5: Test Error Handling
**Goal:** See how backend handles invalid data
**Setup:**
1. Method: POST
2. Path: `/users`
3. Body:
```json theme={"dark"}
{
"email": "invalid-email"
}
```
4. Send request
**Verify:**
* Status code: 400 Bad Request
* Response body shows validation error
* Error message is clear
## Common Test Cases
### ✅ Before Going Live Checklist
Test these scenarios before sending real traffic:
* [ ] **GET request works**
* Returns expected data
* Correct headers injected
* Secrets work
* [ ] **POST request works**
* Creates resource successfully
* Body template works
* Secrets injected correctly
* [ ] **PUT/PATCH request works**
* Updates resource
* Idempotency works (if applicable)
* [ ] **DELETE request works**
* Deletes resource
* Correct permissions
* [ ] **Test each environment**
* Production URL
* Staging URL
* Development URL
* [ ] **Test method configs**
* Each method uses correct config
* Secrets differ per method
* [ ] **Test error cases**
* Invalid data returns 400
* Missing auth returns 401
* Not found returns 404
* [ ] **Test with real data**
* Not just test values
* Actual production-like payloads
## Troubleshooting with Route Tester
### Issue: "Connection refused"
**Possible causes:**
* Target URL is wrong
* Backend server is down
* Firewall blocking KnoxCall IPs
**Debug:**
1. Check target URL in route config
2. Verify backend is running: `curl https://your-backend.com/health`
3. Check backend firewall allows KnoxCall IPs
### Issue: "401 Unauthorized"
**Possible causes:**
* Secret not injected
* Wrong secret value
* Secret expired
**Debug:**
1. Check "Request Headers" section
2. Verify `Authorization` header present
3. Check secret value is correct (not template literal)
4. Test secret directly with backend
### Issue: Template Not Replaced
**Symptoms:** Backend receives `{{secret:api_key}}` literal
**Causes:**
* Secret doesn't exist
* Typo in secret name
* Wrong template syntax
**Debug:**
1. Check "Secrets Injected" section
2. Verify secret exists: **Secrets** page
3. Check spelling: `{{secret:api_key}}` not `{{api_key}}`
### Issue: "502 Bad Gateway"
**Possible causes:**
* Backend took too long (timeout)
* Backend returned invalid response
* Backend crashed
**Debug:**
1. Check response time (>30 seconds?)
2. Check backend logs for errors
3. Test backend directly: `curl https://your-backend.com/endpoint`
### Issue: Wrong Environment Used
**Symptoms:** Request went to production instead of staging
**Debug:**
1. Check environment dropdown selection
2. Verify environment override exists
3. Check "Config Applied" section shows correct environment
## Generate Code Snippets
The Route Tester has a **Code** tab that generates a ready-to-run snippet for calling
the route from your own application. It is useful for copying a working request out of
the tester and into your codebase, CI/CD pipeline, or terminal.
1. Open the Route Tester
2. Switch to the **Code** tab
3. Pick a language: cURL, JavaScript, Python, PHP, Go, Java, C#, Ruby, or C++
4. Click the copy button to copy the snippet to your clipboard
The generated snippet reflects your current method, path, request body, and environment
selection, and includes the correct KnoxCall headers (`x-knoxcall-key`,
`x-knoxcall-route`, and `x-knoxcall-environment` when a non-base environment is
selected). The cURL snippet looks like:
```bash theme={"dark"}
curl -X POST 'https://your-slug.knoxcall.com/api/users' \
-H 'x-knoxcall-key: $KNOXCALL_API_KEY' \
-H 'x-knoxcall-route: ' \
-H 'Content-Type: application/json' \
-d '{"name":"John"}'
```
## Best Practices
### 1. Test Before Deploy
Always test routes before sending production traffic.
### 2. Test All Methods
Don't just test GET - test POST, PUT, DELETE too.
### 3. Test All Environments
Verify dev, staging, prod all work.
### 4. Test Error Cases
Don't only test happy path - test failures too.
### 5. Save Test Cases
Document test cases in route description or wiki.
### 6. Test After Changes
Re-test after changing:
* Route config
* Secrets
* Environment overrides
* Backend URL
### 7. Use Real Data
Test with actual production-like data, not just `{"test": "data"}`.
### 8. Check Response Times
Monitor how long requests take. Optimize if >1 second.
## Limitations
### Rate Limiting Bypassed
Test mode doesn't enforce rate limits. Don't use for load testing.
### IP Authorization Skipped
Test requests don't check client IP whitelist.
### Signatures Not Required
Test mode doesn't require HMAC signatures.
### Real Backend Impact
Test requests **actually hit your backend**. They're not simulated.
**Test requests are real!** They actually call your backend API. Be careful testing:
* DELETE requests (will delete data!)
* POST requests (will create data)
* Payment endpoints (will charge real money!)
Use test/staging environments when possible.
## Related Features
* **Method Configs**: Test different configs per HTTP method
* **Environments**: Test staging before production
* **Secrets**: Verify secrets inject correctly
* **Logs**: View historical requests and replay them
## Next Steps
Test method-specific configurations
Test different environments
View real request logs
Verify secret injection
***
* **Level**: beginner
* **Time**: 5 minutes
`testing`, `debugging`, `routes`, `tools`
# What are Routes?
Source: https://docs.knoxcall.com/essentials/routes/what-are-routes
A quick introduction to routes - the foundation of API proxying in KnoxCall.
# What are Routes?
A **route** is a smart proxy that forwards API requests from your clients to your backend services. Think of it as a secure middleman that adds security, monitoring, and control.
## The Basic Concept
```text theme={"dark"}
Your Client → KnoxCall Route → Your Backend API
```
Instead of calling your backend directly, clients call KnoxCall. KnoxCall validates the request, applies security rules, injects secrets, logs everything, then forwards to your backend.
## Why Use Routes?
Routes solve common API problems:
✅ **Hide backend URLs** - Clients don't know your real backend URLs
✅ **Centralize credentials** - API keys stored securely, not in code
✅ **Monitor everything** - Log all requests and responses
✅ **Add security** - Rate limiting, IP whitelisting, signatures
✅ **Manage environments** - Different configs for dev/staging/prod
## How It Works
**Step 1: Client makes request**
```bash theme={"dark"}
curl https://YOUR-SUBDOMAIN.knoxcall.com/api/users \
-H "x-knoxcall-route: my-route" \
-H "Authorization: Bearer $TOKEN"
```
**Step 2: KnoxCall processes**
* Validates the OAuth token, API key, or IP address
* Checks permissions
* Injects secrets (like API keys)
* Applies rate limits
**Step 3: Forwards to backend**
```http theme={"dark"}
GET https://your-backend.com/api/users
Authorization: Bearer SECRET_FROM_VAULT
```
**Step 4: Returns response**
* Logs request and response
* Triggers alerts if needed
* Returns to client
## Required Headers
Every request needs:
```bash theme={"dark"}
x-knoxcall-route: YOUR_ROUTE_NAME
```
And either:
```bash theme={"dark"}
Authorization: Bearer kc_... # OAuth access token (the SDK sends this for you)
# OR
x-knoxcall-key: tk_... # Legacy API key
# OR
# Your IP whitelisted # IP-based auth
```
## URL Structure
```text theme={"dark"}
https://YOUR-SUBDOMAIN.knoxcall.com/your/api/path
^^^^^^^^^^^^^^
tenant slug
```
* **Subdomain**: Your tenant slug — identifies your tenant (shown in dashboard)
* **Path**: Passed through to backend unchanged
## Common Use Cases
### Webhook Forwarding
```text theme={"dark"}
Stripe Webhook → KnoxCall → Your Server
- Log every webhook
- Verify signatures
- Retry on failure
```
### API Proxy
```text theme={"dark"}
Mobile App → KnoxCall → Backend API
- Hide backend URL
- Manage API keys securely
- Monitor usage
```
### Microservices
```text theme={"dark"}
Frontend → KnoxCall → {
/users → User Service
/orders → Order Service
/payments → Payment Service
}
```
## Quick Example
**1. Create a route:**
* Name: `my-api`
* Target: `https://api.example.com`
**2. Make a request:**
Using the [SDK](/sdks/overview) (after [`knoxcall login`](/api-reference/authentication)):
```typescript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login`
const resp = await client.call("my-api", { path: "/users" });
```
Or raw curl — `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)):
```bash theme={"dark"}
curl https://acme.knoxcall.com/users \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-api"
```
**3. KnoxCall forwards to:**
```text theme={"dark"}
https://api.example.com/users
```
That's it! Your route is working.
## What's Next?
Step-by-step guide to create a route
Configure headers, secrets, and more
Test your routes with curl
Rate limiting, method configs, transformations
***
**Keep it simple:** Start with a basic route, then add advanced features as needed.
# Creating Secrets
Source: https://docs.knoxcall.com/essentials/secrets/creating-secrets
Step-by-step guide to creating and managing encrypted secrets in KnoxCall. Learn about string secrets, OAuth2, and best practices.
# Creating Secrets
Learn how to create and manage encrypted secrets for API keys, passwords, and tokens.
***
## What You'll Learn
* How to create string secrets (API keys, passwords)
* How to create OAuth2 secrets (auto-refreshing tokens)
* Naming conventions and best practices
* Secret versioning and rotation
***
## Creating a String Secret
String secrets are the most common type - used for API keys, passwords, tokens, and any sensitive text value.
### Step 1: Navigate to Secrets
1. Click **Secrets** in the sidebar
2. The Secrets page shows a **two-level hierarchy**: collections at the top level, secrets within each collection. Click a collection to see its secrets. Secrets not assigned to a collection appear under **Uncollected**.
3. Click **Add Secret** (at the top level or within a collection)
### Step 2: Choose Secret Type
Select **String Secret**
*For OAuth2 tokens, see [OAuth2 Secrets](#creating-oauth2-secrets) below.*
### Step 3: Enter Secret Details
#### Secret Name (Required)
The name you'll use to reference this secret in routes.
**Format:** lowercase with underscores
**Good examples:**
```text theme={"dark"}
stripe_prod_key
sendgrid_api_key
database_password
github_webhook_secret
twilio_auth_token
printnode_api_key
```
**Bad examples:**
```text theme={"dark"}
secret1 (not descriptive)
StripeKey (use lowercase)
stripe-key (use underscores, not hyphens)
key (too generic)
```
**Why naming matters:**
You'll reference secrets in templates like this:
```text theme={"dark"}
{{secret:stripe_prod_key}}
```
Clear names make your configurations self-documenting.
#### Secret Value (Required)
The actual sensitive value to encrypt and store.
**Examples:**
```text theme={"dark"}
API keys: sk_live_EXAMPLE_abc123xyz789...
Passwords: MySecureP@ssw0rd!2024
Tokens: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
URLs: postgres://user:pass@host:5432/db
Webhook secrets: whsec_abc123xyz789...
```
**Important:**
* Copy from source carefully (no extra spaces!)
* Value is encrypted immediately after saving
* You won't be able to view plaintext again
### Step 4: Save
Click **Create Secret**
✅ **Done!** Your secret is now:
* Encrypted with AES-256-GCM
* Stored securely
* Ready to use in routes
⚠️ **Important:** The plaintext value is now gone from the UI. If you need to reference it later, you'll need to get it from the original source.
***
## Creating OAuth2 Secrets
OAuth2 secrets are special - they **automatically refresh** access tokens when they expire.
### When to Use OAuth2 Secrets
Use OAuth2 for services that require token refresh:
* ✅ Google APIs (Gmail, Drive, Sheets)
* ✅ Microsoft Graph (Office 365, OneDrive)
* ✅ Shopify
* ✅ HubSpot
* ✅ Salesforce
* ✅ Slack
### Step 1: Choose OAuth2 Secret Type
When creating secret, select **OAuth2 Secret**
### Step 2: Fill in OAuth2 Details
#### Secret Name
```text theme={"dark"}
google_oauth_token
shopify_access_token
hubspot_oauth_creds
```
#### OAuth2 Configuration
**Client ID:**
```text theme={"dark"}
123456789.apps.googleusercontent.com
```
*From your OAuth app settings*
**Client Secret:**
```text theme={"dark"}
GOCSPX-abc123xyz789...
```
*From your OAuth app settings*
**Provider:**
```text theme={"dark"}
google
```
*One of the supported OAuth2 providers (e.g. `google`, `microsoft`)*
**Scopes (optional):**
```text theme={"dark"}
https://www.googleapis.com/auth/gmail.readonly
```
*Defaults to the provider's default scopes if omitted*
**Auth URL / Token URL (optional):**
```text theme={"dark"}
https://oauth2.googleapis.com/token
```
*Override the provider's authorize/token endpoints; defaults are filled in for you*
You do **not** paste in an access token, refresh token, or expiry at creation time. KnoxCall stores only your OAuth app's `client_id`/`client_secret` (or an `mtls_certificate_id`) plus the endpoint config. The secret is created with `connection_status: "not_connected"`, and the response includes a minted `redirect_uri`.
### Step 3: Save and Connect
Click **Create Secret**. The response includes a `redirect_uri` on the fixed `oauth.knoxcall.com/callback` host.
✅ **Complete the OAuth flow** to obtain tokens. Visit the authorization URL, approve access at the provider, and the provider redirects back to the minted `redirect_uri`. KnoxCall then exchanges the code for tokens and flips `connection_status` to connected. Once connected, KnoxCall will:
* Monitor token expiry
* Refresh before expiration using the stored refresh token
* Update the access token automatically
* Log refresh events
***
## Secret Naming Conventions
### Pattern: `{service}_{environment}_{type}`
**Examples:**
#### By Service
```text theme={"dark"}
stripe_prod_key
stripe_staging_key
sendgrid_prod_api_key
twilio_dev_token
```
#### By Environment
```text theme={"dark"}
database_prod_password
database_staging_password
database_dev_password
```
#### By Purpose
```text theme={"dark"}
webhook_secret_github
webhook_secret_shopify
api_key_printnode
api_key_openai
```
### Multiple Secrets for Same Service
When you need different keys for different purposes:
```text theme={"dark"}
stripe_prod_live_key (live transactions)
stripe_prod_restricted_key (read-only)
stripe_test_key (testing)
```
***
## Environment-Specific Secrets
Create separate secrets for each environment:
### Production Secrets
```text theme={"dark"}
Name: stripe_prod_key
Value: sk_live_abc123...
Description: Stripe production - live transactions
Tags: production, payment, critical
```
### Staging Secrets
```text theme={"dark"}
Name: stripe_staging_key
Value: sk_test_xyz789...
Description: Stripe staging - test mode
Tags: staging, payment
```
### Development Secrets
```text theme={"dark"}
Name: stripe_dev_key
Value: sk_test_dev123...
Description: Stripe development - local testing
Tags: development, payment
```
**Then use in route overrides:**
```text theme={"dark"}
Production environment:
Header: Authorization: Bearer {{secret:stripe_prod_key}}
Staging environment:
Header: Authorization: Bearer {{secret:stripe_staging_key}}
Development environment:
Header: Authorization: Bearer {{secret:stripe_dev_key}}
```
***
## Best Practices
### ✅ Do
**1. Use descriptive names**
```text theme={"dark"}
✅ stripe_prod_live_key
❌ key1
```
**2. Document with descriptions**
```text theme={"dark"}
✅ "Stripe production API key - rotate every 90 days - expires Mar 2025"
❌ (no description)
```
**3. Separate by environment**
```text theme={"dark"}
✅ stripe_prod_key, stripe_staging_key, stripe_dev_key
❌ stripe_key (used everywhere)
```
**4. Tag appropriately**
```text theme={"dark"}
✅ Tags: production, payment, critical
❌ (no tags)
```
**5. Version for rotation**
```text theme={"dark"}
Secret v1: stripe_prod_key (old)
Secret v2: stripe_prod_key (new, rotated)
```
**6. Use OAuth2 type when applicable**
```text theme={"dark"}
✅ OAuth2 secret for Google APIs (auto-refresh)
❌ String secret that expires (manual update needed)
```
### ❌ Don't
**1. Don't commit secrets to git**
```text theme={"dark"}
❌ .env file in repository
✅ Store in KnoxCall secrets
```
**2. Don't reuse across environments**
```text theme={"dark"}
❌ Same key for prod and dev
✅ Separate keys per environment
```
**3. Don't use generic names**
```text theme={"dark"}
❌ api_key, secret, password
✅ stripe_prod_key, database_prod_password
```
**4. Don't skip descriptions**
```text theme={"dark"}
❌ No description
✅ "Production database - rotate quarterly - last rotated Jan 2025"
```
**5. Don't expose test keys in production**
```text theme={"dark"}
❌ stripe_test_key in production environment
✅ stripe_prod_key in production
```
***
## Secret Security
### How Secrets Are Stored
1. **Encryption:** AES-256-GCM (military-grade)
2. **Envelope encryption:** Each secret has unique encryption key
3. **Master key:** Stored separately, never exposed
4. **Integrity:** SHA-256 checksum verification
### Who Can Access Secrets
**View encrypted secrets:**
* All team members with access
**View plaintext values:**
* **Nobody** - not even admins!
* Plaintext only visible when:
* Initially created (one-time viewing)
* Decrypted server-side during requests
* Never shown in logs or UI
**Use secrets in routes:**
* Anyone with route edit permissions
### Secret Transmission
When requests use secrets:
1. Client makes request to KnoxCall
2. KnoxCall loads route config
3. Sees `{{secret:stripe_key}}` template
4. Decrypts secret **server-side**
5. Injects into request to backend
6. Client never sees plaintext!
***
## Common Use Cases
### 1. Payment APIs
**Stripe:**
```text theme={"dark"}
Name: stripe_prod_key
Value: sk_live_EXAMPLE_abc123xyz789...
Description: Stripe production secret key
```
**Usage in route:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
### 2. Email Services
**SendGrid:**
```text theme={"dark"}
Name: sendgrid_api_key
Value: SG.abc123xyz789...
Description: SendGrid API key for transactional emails
```
**Usage:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:sendgrid_api_key}}"
}
```
### 3. SMS Services
**Twilio:**
```text theme={"dark"}
Name: twilio_auth_token
Value: abc123xyz789...
Description: Twilio auth token for SMS
```
**Usage:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:twilio_auth_token}}"
}
```
### 4. Database Credentials
**PostgreSQL:**
```text theme={"dark"}
Name: database_prod_url
Value: postgres://user:pass@host:5432/db
Description: Production database connection string
```
**Usage in body:**
```json theme={"dark"}
{
"connection_string": "{{secret:database_prod_url}}"
}
```
### 5. Webhook Secrets
**GitHub:**
```text theme={"dark"}
Name: github_webhook_secret
Value: whsec_abc123...
Description: GitHub webhook verification secret
```
**Usage:**
```json theme={"dark"}
{
"X-Hub-Signature": "{{secret:github_webhook_secret}}"
}
```
### 6. Printing Services
**PrintNode:**
```text theme={"dark"}
Name: printnode_api_key
Value: abc123xyz789...
Description: PrintNode API key for cloud printing
```
**Usage:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:printnode_api_key}}"
}
```
***
## Secret Versioning
KnoxCall tracks secret versions for safe rotation.
### Creating a New Version
When rotating a secret:
1. Go to **Secrets**
2. Click on secret name
3. Click **Add New Version**
4. Enter new value
5. Save
**What happens:**
* New version becomes active immediately
* Old version preserved (for rollback)
* All routes using secret now use new value
* Zero downtime!
### Viewing Version History
1. Click secret name
2. Go to **Version History** tab
3. See all versions with timestamps
4. Can rollback if needed
***
## Troubleshooting
### Secret Not Found in Templates
**Error:** `Secret 'stripe_key' not found`
**Causes:**
* Typo in secret name
* Secret was deleted
* Wrong syntax in template
**Solution:**
1. Check secret exists: **Resources** → **Secrets**
2. Verify spelling matches exactly
3. Check template syntax: `{{secret:name}}`
### Cannot View Secret Value
**Symptom:** Want to see what the value is.
**Cause:** Plaintext never shown after creation (security feature).
**Solution:**
* Get value from original source
* Or create new version with known value
### Secret Not Injecting
**Symptom:** Backend receives literal `{{secret:stripe_key}}` text.
**Causes:**
* Template syntax error
* Secret doesn't exist
* Injection disabled
**Debug:**
1. Check logs - what was actually sent?
2. Verify template syntax is correct
3. Test secret exists and name matches
4. Verify inject headers is enabled on route
***
## Quick Reference
| Task | Steps |
| - | - |
| **Create string secret** | Resources → Secrets → Add Secret → String |
| **Create OAuth2 secret** | Resources → Secrets → Add Secret → OAuth2 |
| **Rotate secret** | Click secret → Add New Version |
| **View versions** | Click secret → Version History |
| **Delete secret** | Click secret → Delete (⚠️ permanent) |
***
## Next Steps
How to inject secrets into routes
Complete secrets guide
Set up OAuth2 auto-refresh
Best practices for rotating secrets
***
**Security Reminder:** Never commit secrets to git, share via email, or hardcode in applications. Always use KnoxCall's encrypted secret storage.
# Environment-Specific Secret Values
Source: https://docs.knoxcall.com/essentials/secrets/environment-specific-secrets
Configure different secret values for production, staging, and development environments. One secret, multiple values with automatic environment-based resolution.
# Environment-Specific Secret Values
Use a single secret with different values for each environment. KnoxCall automatically uses the correct value based on the route's active environment—no manual switching required.
## The Problem
Traditional approach requires separate secrets per environment:
```text theme={"dark"}
❌ Old Way: 3 Secrets
├─ stripe_production_key → sk_live_abc123
├─ stripe_staging_key → sk_test_staging_456
└─ stripe_development_key → sk_test_dev_789
Route config must change per environment:
Production: {{secret:stripe_production_key}}
Staging: {{secret:stripe_staging_key}}
Development: {{secret:stripe_development_key}}
```
**Problems:**
* Accidental use of production keys in staging
* Manual route config updates when switching environments
* Difficult to audit which env uses which key
* Secret sprawl (3x secrets)
## The Solution
One secret, multiple environment-specific values:
```text theme={"dark"}
✅ New Way: 1 Secret
stripe_api_key
├─ production: sk_live_abc123
├─ staging: sk_test_staging_456
└─ development: sk_test_dev_789
Route config stays the same:
All environments: {{secret:stripe_api_key}}
```
**Benefits:**
* ✅ Automatic environment-based resolution
* ✅ Hard fail if environment value missing (prevents accidents)
* ✅ Simplified route configuration
* ✅ Clear audit trail per environment
## How It Works
### Step 1: Create Secret
Create a secret normally:
```text theme={"dark"}
Name: stripe_api_key
Type: String
```
Leave the initial value empty—we'll set environment-specific values next.
### Step 2: Configure Environment Values
In the secret detail page:
1. **Select environment** from dropdown (top right)
2. **Enter value** for that environment
3. **Save**
4. **Repeat** for other environments
**Example:**
```text theme={"dark"}
Environment: production
Value: sk_live_EXAMPLE_4eC39HqLyjW
[Save]
Environment: staging
Value: sk_test_staging_BQokikJOvBiI2HlWgH4olfQ2
[Save]
Environment: development
Value: sk_test_dev_4eC39HqLyjWDarjtT1zdp7dc
[Save]
```
### Step 3: Reference in Route
Use the secret in your route config:
```json theme={"dark"}
{
"headers": {
"Authorization": "Bearer {{secret:stripe_api_key}}"
}
}
```
### Step 4: Automatic Resolution
When a request comes in:
```text theme={"dark"}
1. Client calls route
2. Route has active environment: "production"
3. KnoxCall looks up stripe_api_key environment config
4. Finds production value: sk_live_EXAMPLE_4eC39HqLyjW
5. Injects value into request
6. Proxies to Stripe API ✅
```
If route is in staging environment:
```text theme={"dark"}
Same route, staging environment:
→ Uses sk_test_staging_BQokikJOvBiI2HlWgH4olfQ2 automatically
```
## Hard Fail Behavior
**Safety feature**: If a secret lacks a value for the requested environment, the request **fails immediately**.
```text theme={"dark"}
Scenario:
- Secret: stripe_api_key
- Configured environments: production, staging
- Route environment: development ❌
Result:
The proxy refuses to inject the secret and the request is blocked.
The client receives:
HTTP 401 Unauthorized
{
"error": "Unauthorized"
}
```
The detailed reason — `Secret 'stripe_api_key' () has no value configured for environment 'development'` — is recorded server-side in the request log (`api_requests.error`), not returned to the caller. Error responses are normalized so callers can't enumerate tenant or secret configuration from the status code or message.
**Why no fallback?**
* Prevents accidental production key usage in dev/staging
* Forces explicit configuration
* Makes missing config obvious during testing
* Complies with principle of least privilege
**Solution:**
Add the missing environment value or change route's active environment.
## Supported Secret Types
Environment-specific values work with **all secret types**:
### String Secrets
```text theme={"dark"}
API Key: stripe_api_key
├─ production: sk_live_abc123
├─ staging: sk_test_staging_456
└─ development: sk_test_dev_789
```
### OAuth2 Secrets
```text theme={"dark"}
OAuth Client: google_oauth
├─ production:
│ client_id: prod-client-id.apps.googleusercontent.com
│ client_secret: GOCSPX-prod-abc123
│
├─ staging:
│ client_id: staging-client-id.apps.googleusercontent.com
│ client_secret: GOCSPX-staging-def456
│
└─ development:
client_id: dev-client-id.apps.googleusercontent.com
client_secret: GOCSPX-dev-ghi789
```
### Certificate Secrets (mTLS)
```text theme={"dark"}
Client Certificate: bank_mtls_cert
├─ production:
│ cert: -----BEGIN CERTIFICATE----- (prod cert)
│ key: -----BEGIN PRIVATE KEY----- (prod key)
│
├─ staging:
│ cert: -----BEGIN CERTIFICATE----- (staging cert)
│ key: -----BEGIN PRIVATE KEY----- (staging key)
│
└─ development:
cert: -----BEGIN CERTIFICATE----- (dev cert)
key: -----BEGIN PRIVATE KEY----- (dev key)
```
## Advanced Usage
### Partial Environment Coverage
You can configure only the environments you need:
```text theme={"dark"}
Secret: internal_api_key
Configured:
✅ production
✅ staging
❌ development (not configured)
Routes in development environment:
→ Will fail (hard fail)
→ Forces you to add dev value or use different secret
```
### Duplicating Environments
Clone an existing environment's value:
1. Go to secret detail page
2. Select source environment (e.g., "production")
3. Click **Duplicate to another environment**
4. Choose target environment (e.g., "staging")
5. **Modify** the duplicated value if needed
6. Save
**Use case:** Copy production config to staging, then change specific values.
### Deleting Environment Values
**Protected deletion**: Cannot delete an environment value if any route is actively using it in that environment.
```text theme={"dark"}
Scenario:
- Secret: stripe_api_key has production value
- Route "Stripe Charges" is active in production environment
- Try to delete production value
Result:
❌ Error: Cannot delete - 1 route using this secret in production
Routes: Stripe Charges (route_abc123)
```
**Solution:**
1. Switch routes to different environment, OR
2. Update routes to use different secret, OR
3. Delete the routes first
### Viewing All Environments
In the secret detail page, see all configured environments at a glance:
```text theme={"dark"}
Secret: stripe_api_key
Environment Status:
✅ production (configured, 4 routes using)
✅ staging (configured, 2 routes using)
✅ development (configured, 0 routes using)
⚠️ testing (not configured)
```
## API Reference
### Get Secret Environments
```http theme={"dark"}
GET /admin/secrets/{secret_id}/environments
Response:
{
"secret_id": "uuid",
"secret_name": "stripe-prod-key",
"secret_type": "string",
"base_environment": "production",
"environments": [
{
"environment_name": "production",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-20T14:30:00Z"
},
{
"environment_name": "staging",
"created_at": "2025-01-15T10:05:00Z",
"updated_at": "2025-01-15T10:05:00Z"
}
]
}
```
Note: Values are **never** returned via API for security.
### Create/Update Environment Value
```http theme={"dark"}
PUT /admin/secrets/{secret_id}/environments/{environment_name}
Body (String secret only):
{
"value": "sk_test_abc123"
}
Note: This endpoint only accepts `string` secrets. OAuth2 and certificate secrets have their own
dedicated endpoints for managing environment-level values.
```
### Delete Environment Value
```http theme={"dark"}
DELETE /admin/secrets/{secret_id}/environments/{environment_name}
Response:
{
"ok": true
}
Or if in use by routes:
{
"error": "Cannot remove environment 'production' from this secret. It is used by the following routes in that environment: stripe-charges. Remove the secret from those routes first.",
"routes": [{ "id": "uuid", "name": "stripe-charges" }]
}
```
### Duplicate Environment
```http theme={"dark"}
POST /admin/secrets/{secret_id}/environments/{source_env}/duplicate
Body:
{
"target_environment": "development"
}
Response:
{
"ok": true,
"environment": "development",
"message": "Successfully created environment 'development' from 'staging'"
}
```
## Migration Guide
### From Naming Convention to Environment Configs
**Before:**
```text theme={"dark"}
Secrets:
- stripe_production_key
- stripe_staging_key
- stripe_development_key
Route config (production):
{
"Authorization": "Bearer {{secret:stripe_production_key}}"
}
```
**After:**
```text theme={"dark"}
Secret: stripe_api_key
├─ production: (value from stripe_production_key)
├─ staging: (value from stripe_staging_key)
└─ development: (value from stripe_development_key)
Route config (all environments):
{
"Authorization": "Bearer {{secret:stripe_api_key}}"
}
```
**Steps:**
1. **Create new secret**: `stripe_api_key` (no initial value)
2. **Copy values**:
* Environment: production → Paste value from `stripe_production_key`
* Environment: staging → Paste value from `stripe_staging_key`
* Environment: development → Paste value from `stripe_development_key`
3. **Update routes**:
* Change `{{secret:stripe_production_key}}` to `{{secret:stripe_api_key}}`
* Remove environment-specific references
4. **Test** in staging first
5. **Deploy** to production
6. **Delete old secrets** after 48 hours
## Best Practices
### ✅ Do This
1. **Use environment configs for new secrets**
* Simpler, safer, better audit trail
2. **Configure all environments you use**
* Don't leave gaps (hard fail protects you)
3. **Test in staging first**
* Verify correct values loaded per environment
4. **Use descriptive names**
* `stripe_api_key` not `secret_42`
* Easier to manage across environments
5. **Document your environments**
* Keep a list of which environments exist
* Standard: production, staging, development
### ❌ Avoid This
1. **Don't mix approaches for same service**
* Either use env configs OR naming convention
* Don't use both for Stripe (confusing)
2. **Don't delete environment values carelessly**
* Check if routes are using them first
* System prevents deletion, but verify manually
3. **Don't share values across environments**
* Never use production keys in staging
* Even if system allows it, don't do it
4. **Don't skip testing after migration**
* Always test in dev/staging first
* Verify hard fail works as expected
## Troubleshooting
### "Secret not configured for environment"
**Problem:**
```text theme={"dark"}
Route fails with:
"Secret 'api_key' not configured for environment 'staging'"
```
**Solutions:**
1. Add the missing environment value
2. Change route's active environment
3. Use a different secret that has the environment
### Can't delete environment value
**Problem:**
```text theme={"dark"}
Cannot delete - 3 routes using this secret in production
```
**Solution:**
```sql theme={"dark"}
-- Find routes using this secret
SELECT r.name, r.id
FROM routes r
JOIN route_environment_configs rec ON r.id = rec.route_id
WHERE rec.environment_name = 'production'
AND rec.active = true;
-- Then either:
1. Switch those routes to different environment
2. Update routes to use different secret
3. Accept that you can't delete (it's in use!)
```
### Environment dropdown not showing
**Problem:** Can't see environment selector in secret detail page
**Possible causes:**
1. Secret type doesn't support environments (shouldn't happen - all types do)
2. Browser cache issue - hard refresh (Ctrl+F5)
3. Old UI version - ensure admin-ui is up to date
**Solution:**
```bash theme={"dark"}
cd admin-ui && npm run build
pm2 restart knoxcall
```
### Wrong value being used
**Problem:** Route in staging is using production value
**Debug steps:**
1. Check route's active environment (top right dropdown)
2. Verify secret has value for that environment
3. Check route config uses correct secret name
4. Test with a different route
**Common cause:** Route's active environment is set to "production" even though you think it's staging.
## Security Considerations
### Encryption
All environment values are encrypted individually:
```text theme={"dark"}
Secret: stripe_api_key
Each environment value:
├─ Encrypted with unique data encryption key (DEK)
├─ DEK encrypted with master key (KEK)
├─ Stored: ciphertext + IV + auth tag
└─ Decrypted only when route resolves secret
```
### Audit Trail
Every environment value change is logged:
```sql theme={"dark"}
SELECT
resource_id, -- the secret's id
action, -- e.g. 'create', 'update', 'rotate', 'delete'
details, -- JSON: { name, environment, ... }
user_id, -- set for dashboard users (null for API-key access)
ip_address,
created_at
FROM audit_logs
WHERE resource_type = 'secret'
AND resource_id = 'sec_abc123';
```
### Access Control
* Only tenant admins can view/edit secret environment configs
* The client API requires a KnoxCall OAuth access token or API key (`Authorization: Bearer `; legacy API keys may also use the `x-api-key` header — see [Authentication](/api-reference/authentication)), not a session JWT
* No way to retrieve values via API (write-only)
* Environment values never logged or transmitted in plaintext
### Control mappings
Which requirement in your own programme this feature speaks to. These are
mappings, not attestations KnoxCall holds — see the
[trust page](https://knoxcall.com/trust).
* **PCI DSS**: Separate production and test credentials
* **SOC 2**: Audit trail of secret changes per environment
* **GDPR**: Environment isolation for data protection
* **HIPAA**: Segregation of production and non-production
## Next Steps
Learn how to create secrets in KnoxCall
Reference secrets in route configurations
Understand KnoxCall environments
Best practices for rotating secrets
***
* **Level**: Intermediate
* **Time**: 10 minutes
* **Prerequisites**: Basic understanding of secrets and environments
`secrets`, `environments`, `configuration`, `security`, `credentials`
# Secret Rotation and Management
Source: https://docs.knoxcall.com/essentials/secrets/secrets-overview
Learn how to rotate secrets safely, manage per-environment credentials, and organize secrets effectively.
# Secret Rotation and Management
Learn how to rotate secrets safely, organize them effectively, and manage per-environment credentials.
## Why Rotate Secrets?
**Security reasons:**
* Limit blast radius if compromised
* Meet compliance requirements (PCI DSS, SOC 2)
* Best practice for production systems
**Recommended schedule:**
* **Critical secrets** (production API keys): Every 30 days
* **Standard secrets**: Every 90 days
* **Database passwords**: Every 90 days
* **OAuth tokens**: When revoked by provider
## Safe Rotation Process
### Step-by-Step
**1. Create new secret** with different name:
```text theme={"dark"}
Old: stripe_api_key
New: stripe_api_key_v2
```
**2. Update routes** to use new secret:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_api_key_v2}}"
}
```
**3. Test thoroughly** in staging first
**4. Deploy to production**
**5. Monitor** for 24-48 hours
**6. Delete old secret** when confirmed working
### Why This Works
* Zero downtime
* Easy rollback (keep old secret temporarily)
* Clear audit trail
### What NOT to Do
❌ **Don't delete the old secret immediately**
❌ **Don't delete immediately**
* Keep old secret for 48 hours
* Allows rollback if issues
## Environment-Specific Secrets
KnoxCall supports two approaches for managing secrets across environments:
### Approach 1: Environment Configurations (Recommended)
**Best for**: Single secret with different values per environment
Create one secret with environment-specific values:
```text theme={"dark"}
Secret: stripe_api_key
Environments:
production: sk_live_EXAMPLE_abc123xyz789
staging: sk_test_staging_456def
development: sk_test_dev_789ghi
```
**How to configure:**
1. Create a secret: `stripe_api_key`
2. In the secret detail page, select environment from dropdown
3. Add value for each environment:
* Switch to "production" → Enter live key
* Switch to "staging" → Enter test key
* Switch to "development" → Enter dev key
**Using in routes:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_api_key}}"
}
```
**Behavior:**
* Route in "production" environment → Uses production value
* Route in "staging" environment → Uses staging value
* **Hard fail if missing**: Route returns error if secret lacks the environment value
* No fallbacks or defaults (prevents accidental production key usage in dev)
**Benefits:**
* ✅ One secret name across all environments
* ✅ Impossible to accidentally use wrong key
* ✅ Clear environment isolation
* ✅ Simplified route configuration
### Approach 2: Naming Convention (Legacy)
**Best for**: Backward compatibility or when you prefer separate secrets
Create different secrets per environment:
```text theme={"dark"}
Secrets:
stripe_production_api_key
stripe_staging_api_key
stripe_development_api_key
```
**Production route:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_production_api_key}}"
}
```
**Staging route:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_staging_api_key}}"
}
```
**Drawbacks:**
* ❌ Must update route config when changing environments
* ❌ Possible to reference wrong secret in wrong environment
* ❌ More secrets to manage
### Comparison
| Feature | Environment Configs | Naming Convention |
| - | - | - |
| Secret count | 1 per service | 3+ per service |
| Route config | Same everywhere | Different per env |
| Safety | Hard fail if missing | Silent failure possible |
| Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Recommended | ✅ Yes | Legacy support |
**Migration tip**: Use environment configs for new secrets, migrate old ones gradually
## Organization Strategies
### Group by Service
```text theme={"dark"}
stripe_api_key
stripe_webhook_secret
stripe_publishable_key
sendgrid_api_key
sendgrid_webhook_key
aws_access_key_id
aws_secret_access_key
aws_region
```
### Group by Environment
```text theme={"dark"}
production_stripe_key
production_sendgrid_key
production_database_url
staging_stripe_key
staging_sendgrid_key
staging_database_url
```
Choose one strategy and be consistent!
## Multiple Secrets in One Route
Inject multiple credentials:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_api_key}}",
"X-Partner-Key": "{{secret:partner_api_key}}",
"X-Database": "{{secret:database_url}}"
}
```
All injected server-side, none exposed to client.
## Cleaning Up Secrets
### Find Unused Secrets
To reduce attack surface, periodically remove secrets you no longer need, such as:
* Test or temporary secrets
* Secrets left over from completed rotations
* Credentials for routes that have been deleted
KnoxCall protects you from deleting a secret that is still in use: if a secret is
still referenced by any route, the delete request is rejected with the name of the
route that depends on it. Remove the reference from all routes first, then delete
the secret.
## Common Patterns
### API Key Rotation
```text theme={"dark"}
1. Generate new key from provider (Stripe, SendGrid, etc)
2. Create secret: service_key_v2
3. Update routes to use v2
4. Test thoroughly
5. Delete old secret after 48h
```
### Database Password Rotation
```text theme={"dark"}
1. Create new password in database
2. Create secret: db_password_v2
3. Update routes to use v2
4. Test all database connections
5. Remove old password from database
6. Delete old secret
```
### OAuth Token Refresh
```text theme={"dark"}
1. Refresh token via OAuth flow
2. Create secret: service_oauth_token_v2
3. Update routes
4. Test authentication
5. Revoke old token with provider
6. Delete old secret
```
## Troubleshooting
**Routes failing after rotation:**
* Verify new secret value is correct
* Check all routes were updated
* Look for cached old values
* Roll back to old secret if needed
**"Secret not found" errors:**
* Check spelling (case-sensitive)
* Verify secret exists in Secrets page
* Confirm route is using correct name
**Third-party API returns 401:**
* New secret value might be wrong
* API key might be expired/revoked
* Test secret value directly with provider
## Rotation Checklist
Use this checklist for each rotation:
* [ ] Document which routes use this secret
* [ ] Generate new credential from provider
* [ ] Create new secret with v2 suffix
* [ ] Update all routes in staging
* [ ] Test thoroughly in staging
* [ ] Update routes in production
* [ ] Monitor for 48 hours
* [ ] Verify no errors
* [ ] Delete old secret
* [ ] Update documentation
* [ ] Schedule next rotation (calendar)
## Next Steps
* Set up environments for per-env secrets
* Learn about securing your routes
* Configure monitoring and alerting
**Need help?** Use the support chat!
***
* **Level**: intermediate
* **Time**: 10 minutes
`secrets`, `security`, `rotation`, `management`
# Sharing Secrets
Source: https://docs.knoxcall.com/essentials/secrets/sharing-secrets
Share an API key with another company like 1Password — except they can use it without ever seeing the raw value, only toward approved domains, and you can stop sharing instantly.
# Sharing Secrets
Share a secret with an external party the way you'd share a password in 1Password — but with three things a password manager can't do:
* **They never see the value.** By default the recipient can *use* the secret (KnoxCall injects it into their API calls) but can never read the raw key.
* **It's locked to approved destinations.** The secret can only be sent to hosts you choose (e.g. `api.stripe.com`). It cannot be exfiltrated to a URL the recipient controls.
* **You can stop instantly.** Revoke a share and the recipient loses access on their very next request — no rotation, no waiting.
Because there is a **single canonical copy** of the secret (yours), any change you make — a rotation, a new value — is instantly live for everyone you've shared it with. Nothing to re-send.
***
## How it works
1. From a secret's **Sharing** tab, you invite someone by email and pick:
* an **access level** (below),
* the **destination domains** the secret may be used against,
* which **environment**'s value to share.
2. The recipient gets an email with a claim link. They are **never** added to your workspace — instead they sign up for (or sign in to) their **own** KnoxCall workspace, where a **linked secret** appears.
3. In their workspace, the linked secret behaves like any other secret in their routes — but it holds no value of its own. At request time KnoxCall resolves it to your canonical value, injects it, and (if it's a `use only` share) never exposes it.
## Access levels
| Level | Can inject into routes | Can reveal the value | Can edit the value |
| - | :-: | :-: | :-: |
| **Use only** (default) | ✅ | ❌ | ❌ |
| **Can view** | ✅ | ✅ (with step-up verification, every reveal) | ❌ |
| **Can manage** | ✅ | ✅ (with step-up verification, every reveal) | ✅ (edits your canonical secret) |
Revealing a shared value takes the same fresh step-up verification as revealing any secret in the recipient's own workspace (an authenticator code, a passkey or a security key, once per reveal), from the Owner, an Admin, or an Editor with **Reveal secrets** turned on. It is recorded as `plaintext.reveal` in **both** workspaces' audit logs before the value is decrypted: the recipient's entry names the person, and yours (the owner's) names the recipient workspace. If either entry cannot be recorded, the reveal is refused.
`Use only` is the headline capability — grant the ability to *use* a credential without ever handing over the credential itself.
### Who can create a readable share
Creating a **Can view** or **Can manage** share lets the recipient read the value in their own
workspace, so it is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. Shares are created in the dashboard only; the Management API (`/v1`) has no share endpoint. A **Use only** share is not a reveal: the recipient never
sees the value and the domain lock bounds where it can be sent, so creating one is unchanged.
## Domain-locking
Domain-locking is **required by default**. When you share a secret you choose the destinations it may be injected toward:
* exact hostnames — `api.stripe.com`
* a single wildcard label — `*.example.com` (matches `a.example.com`, not the apex)
At request time, KnoxCall checks the resolved upstream host against this list **before sending anything**. If a recipient points a route at a host you didn't approve, the request is refused and the value is never transmitted. Choosing "any domain" is possible but is an explicit, audited opt-out — for a `use only` share it removes the only thing preventing the recipient from forwarding the value to themselves, so we don't recommend it.
## Revoking
Open the **Sharing** tab and choose **Stop sharing** on any recipient. Access ends immediately — the check happens on every request, uncached. Deleting a secret that is actively shared prompts you to revoke those shares first (or to revoke-and-delete in one step).
***
## What "never see the value" actually means (the honest version)
This is important to state plainly. Linked secret sharing is **recipient-opaque, platform-trusted, and destination-constrained** — it is **not** zero-knowledge:
* **Recipient-opaque:** the value is never revealed to the recipient through any product surface, and it never leaves KnoxCall toward a destination you didn't approve.
* **Platform-trusted:** KnoxCall's control plane decrypts your secret server-side to inject it (exactly as it does for your own routes today). The "never reveal" guarantee is an authorization + destination boundary, not a cryptographic one.
* **Your kill-switch:** if you use **customer-managed keys (BYOK)**, sealing your KMS revokes every share instantly and cryptographically — nothing can be injected.
In short: the recipient can *use* the credential against the destinations you allow, and can never see it or send it elsewhere — but they are trusting KnoxCall to hold the key, the same as you are.
Linked secrets work with the classic proxy (Routes). Sharing string, OAuth2, and certificate secrets is supported. Live and Test are isolated: a Live share never resolves a Test secret.
# Using Secrets in Routes
Source: https://docs.knoxcall.com/essentials/secrets/using-secrets
Complete guide to injecting secrets into routes. Learn template syntax, headers, body injection, and environment-specific secrets.
# Using Secrets in Routes
Learn how to inject encrypted secrets into your route requests using KnoxCall's template system.
***
## Template Syntax
Secrets are injected using **template syntax** in your route configuration.
### Basic Format
```text theme={"dark"}
{{secret:SECRET_NAME}}
```
**Components:**
* `{{` - Opening delimiter
* `secret:` - Type identifier (required)
* `SECRET_NAME` - Your secret's name
* `}}` - Closing delimiter
### Example
If you have a secret named `stripe_prod_key`:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
At runtime, KnoxCall replaces this with:
```json theme={"dark"}
{
"Authorization": "Bearer sk_live_EXAMPLE_abc123xyz789..."
}
```
***
## Injecting Secrets in Headers
The most common use case - adding secrets to HTTP headers.
### Step 1: Edit Route
1. Go to **Routes**
2. Click on your route
3. Scroll to **Inject Headers** section
### Step 2: Add Header with Secret
Click **Add Header** or edit the JSON directly:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
### Step 3: Save
Click **Save Changes**
✅ All requests through this route now have the secret injected!
### Common Header Patterns
#### Bearer Token
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:api_key}}"
}
```
Used by: Stripe, GitHub, most REST APIs
#### API Key Header
```json theme={"dark"}
{
"X-API-Key": "{{secret:sendgrid_api_key}}"
}
```
Used by: SendGrid, Mailgun, custom APIs
#### Basic Authentication
```json theme={"dark"}
{
"Authorization": "Basic {{secret:basic_auth_credentials}}"
}
```
**Note:** Secret value should be base64-encoded `username:password`
#### Custom Headers
```json theme={"dark"}
{
"X-Custom-Token": "{{secret:custom_token}}",
"X-Webhook-Secret": "{{secret:webhook_secret}}",
"X-Client-ID": "{{secret:client_id}}"
}
```
***
## Injecting Secrets in the Request Body
KnoxCall can substitute placeholders **inside the request body your client sends** — not just headers. This lets your application reference secrets by name (or by ID) without ever holding the actual values. KnoxCall resolves the placeholders server-side, swaps them for the real decrypted values, and forwards the rewritten body upstream.
### How it works
1. Your client sends a POST/PUT/PATCH with placeholder tokens in the body
2. KnoxCall reads the body, scans for `{{secret:name}}` / `{{secret_id:uuid}}` / `{{var:key}}`
3. KnoxCall checks that each secret named is one the route's own template already sends ([see below](#which-secrets-your-client-may-name)), then resolves it against your tenant's secrets for the route's **active environment**
4. KnoxCall rewrites `Content-Length` and forwards the substituted body to the upstream API
5. Your application never needs to possess the raw credential
### Which secrets your client may name
A placeholder your client sends resolves only for a secret **the route already sends**: one that the route's own header or body template references, for the method you are calling. The same rule applies to placeholders your client puts in request headers.
If your client names any other secret in your account, KnoxCall refuses the request with `403` and `X-Knox-Error: client_placeholder_not_bound`. The secret it names is never decrypted, and nothing reaches the upstream API. The error message is the same whether or not the named secret exists.
If KnoxCall cannot complete that check, for example during a brief database outage, it refuses the request with `503` and `X-Knox-Error: client_placeholder_binding_unavailable` rather than forwarding it. Nothing reaches the upstream API, and the request is safe to retry.
This stops a credential that can call one route from pulling a different route's secret into a request it controls.
* **Same secret, either form.** If the route's template sends `{{secret_id:550e8400-e29b-41d4-a716-446655440000}}`, your client may send that placeholder or the same secret by name, `{{secret:stripe_prod_key}}`.
* **This method's template.** If the route has a different configuration per HTTP method, only the configuration for the method you are calling counts. A secret that only the `DELETE` configuration sends cannot be named in a `POST`.
* **Not the mTLS certificate.** A route's client certificate is presented during the TLS handshake and never sent in a request, so a client cannot name it.
* **Accounts created before this rule was deployed** are report-only until they are switched over: the placeholder still resolves, and each use is recorded in your audit log as `route.client_placeholder_not_bound`. Accounts created since are refused as described above.
The examples below assume the route's own header template already sends the secret they name.
### Placeholder forms accepted in the body
| Form | Lookup | Example | Recommended |
| - | - | - | - |
| `{{secret_id:UUID}}` | By secret **UUID** — stable, rename-safe | `{{secret_id:550e8400-e29b-41d4-a716-446655440000}}` | ✅ **Preferred** |
| `{{secret:NAME}}` | By secret **name** — legacy, human-friendly | `{{secret:stripe_prod_key}}` | ⚠️ Works, but breaks on rename |
| `{{var:QUERY_PARAM}}` | Value of an incoming query string parameter | `{{var:user_id}}` | — |
**Prefer the UUID form (`{{secret_id:...}}`) wherever possible.** A secret's UUID is permanent and never changes, so your code and templates keep working even if someone renames the secret in the dashboard. The name-based form (`{{secret:...}}`) is supported for backwards compatibility and readability, but any rename will silently break every reference to it and the next request will hard-fail with "Missing secret".
Also note: `{{secret:UUID}}` is **not** a shortcut for ID-based lookup — it would try to find a secret literally *named* that UUID string and fail. For ID lookups always use the `secret_id:` prefix.
You can find a secret's UUID on its detail page in the KnoxCall dashboard, or in the response of the `create_secret` API / onboarding-agent tool call.
### Example — JSON body
Your client code sends this to KnoxCall (UUID form recommended; `YOUR_ACCESS_TOKEN` is a `kc_…` OAuth access token — see [Authentication](/api-reference/authentication)):
```http theme={"dark"}
POST https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges
Content-Type: application/json
x-knoxcall-route: ROUTE_SLUG
Authorization: Bearer YOUR_ACCESS_TOKEN
{
"api_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}",
"amount": 1000,
"currency": "usd"
}
```
What the upstream API actually receives:
```http theme={"dark"}
POST /v1/charges HTTP/1.1
Host: api.stripe.com
{
"api_key": "sk_live_EXAMPLE_abc123xyz789...",
"amount": 1000,
"currency": "usd"
}
```
### Example — form-urlencoded
```http theme={"dark"}
POST https://YOUR-SUBDOMAIN.knoxcall.com/send
Content-Type: application/x-www-form-urlencoded
api_key={{secret_id:550e8400-e29b-41d4-a716-446655440000}}&to=%2B15551234567&body=hello
```
### Example — plain text / XML
```http theme={"dark"}
POST https://YOUR-SUBDOMAIN.knoxcall.com/soap
Content-Type: application/xml
{{secret_id:550e8400-e29b-41d4-a716-446655440000}}
GetInvoice
```
### Supported Content-Types
KnoxCall only rewrites bodies for **text-shaped** Content-Types, so binary uploads stay byte-identical:
| Content-Type | Body injection |
| - | - |
| `application/json` | ✅ |
| `application/xml`, `text/xml`, `application/*+xml` | ✅ |
| `application/*+json` (e.g. `application/vnd.api+json`) | ✅ |
| `application/x-www-form-urlencoded` | ✅ |
| `application/javascript`, `application/ecmascript` | ✅ |
| `text/*` (plain, html, csv, etc.) | ✅ |
| `multipart/form-data` | ❌ passed through unchanged |
| `application/octet-stream`, `image/*`, `audio/*`, `video/*`, `application/pdf` | ❌ passed through unchanged |
### Safety rules
* **Tenant isolation** — clients can only reference secrets belonging to their own tenant. A UUID guessed from another tenant will hard-fail.
* **Route binding** — clients can only reference a secret the route itself already sends. Any other secret is refused with `403 client_placeholder_not_bound` before it is decrypted; accounts created before this rule was deployed are report-only until switched over (see [Which secrets your client may name](#which-secrets-your-client-may-name)).
* **Environment-aware** — KnoxCall resolves secrets against the route's active environment. If the secret has no value for that environment, the request fails with a clear error (no silent fallback).
* **Size limit** — bodies larger than 1 MiB pass through without substitution to protect the proxy (configurable via `BODY_INJECTION_MAX_BYTES`).
* **UTF-8 required** — the body must be valid UTF-8 with no NUL bytes; anything else is treated as binary and passed through untouched.
* **Hard-fail on missing secrets** — referencing a secret that doesn't exist (or has no value in the active environment) returns an error instead of sending an empty string upstream.
* **Logs are scrubbed** — audit logs store the body **with placeholders intact**, never the resolved secret values.
### Do I still need headers?
Yes: configure the route's template first. A placeholder your client sends, in the body or in a header, resolves only for a secret that the route's own `inject_headers_json` or `inject_body_json` template already references for the method you are calling. Any other secret is refused with `403 client_placeholder_not_bound`, so a client placeholder is not a way to pick a secret per call.
Use the route's header template for stable auth (e.g. `Authorization: Bearer`). Use a body placeholder where the upstream API expects that same credential inside the payload.
### Common Body Patterns
All examples use the **recommended UUID form** — substitute your actual secret IDs.
#### Simple API key in body (UUID, rename-safe)
```json theme={"dark"}
{ "api_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}" }
```
#### Nested objects
```json theme={"dark"}
{
"credentials": {
"username": "admin",
"password": "{{secret_id:7a4c1f22-9e8b-4d60-9b32-12e1f4b9c8a1}}"
}
}
```
#### Legacy name form (works, but breaks if the secret is renamed)
```json theme={"dark"}
{ "api_key": "{{secret:service_api_key}}" }
```
#### Multiple secrets in one request
```json theme={"dark"}
{
"payment_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}",
"email_key": "{{secret_id:6b1f2f33-c1a4-4f75-a0b7-8d91e2a6f3c2}}",
"sms_key": "{{secret_id:a9c82e44-ff3a-4210-9c63-1b8f5cf9e7d1}}"
}
```
#### Mixing a secret with a query-string variable
```http theme={"dark"}
POST /notify?user_id=42
Content-Type: application/json
{
"user": "{{var:user_id}}",
"token": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}"
}
```
***
## Multiple Secrets in One Route
You can use multiple different secrets in the same route configuration.
### Example: Multi-Service Integration
**Route:** `notification-service`
**Headers:**
```json theme={"dark"}
{
"X-Email-Key": "{{secret:sendgrid_api_key}}",
"X-SMS-Key": "{{secret:twilio_auth_token}}"
}
```
**Body:**
```json theme={"dark"}
{
"email_config": {
"api_key": "{{secret:sendgrid_api_key}}"
},
"sms_config": {
"auth_token": "{{secret:twilio_auth_token}}",
"account_sid": "{{secret:twilio_account_sid}}"
},
"push_config": {
"server_key": "{{secret:fcm_server_key}}"
}
}
```
**Result:** All 4 secrets injected in one request!
***
## Environment-Specific Secrets
Use different secrets per environment (dev, staging, production).
### Setup
**Create environment-specific secrets:**
1. **Production:**
```text theme={"dark"}
Name: stripe_prod_key
Value: sk_live_abc123...
```
2. **Staging:**
```text theme={"dark"}
Name: stripe_staging_key
Value: sk_test_xyz789...
```
3. **Development:**
```text theme={"dark"}
Name: stripe_dev_key
Value: sk_test_dev123...
```
### Configure Route Per Environment
**Base environment (production):**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
**Override for staging:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_staging_key}}"
}
```
**Override for development:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_dev_key}}"
}
```
### How to Set Up
1. Go to route → **Environment** tab
2. Select "staging"
3. Edit **Inject Headers**:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_staging_key}}"
}
```
4. Save
5. Repeat for "development"
**Result:**
* Production uses: `stripe_prod_key`
* Staging uses: `stripe_staging_key`
* Development uses: `stripe_dev_key`
All with the same route!
***
## Method-Specific Secrets
Different secrets for different HTTP methods on the same route.
### Example Scenario
Route handles both GET (read) and POST (write) to same API:
* GET needs read-only key
* POST needs full-access key
### Setup
**GET method config:**
```json theme={"dark"}
{
"method": "GET",
"inject_headers_json": {
"Authorization": "Bearer {{secret:api_read_only_key}}"
}
}
```
**POST method config:**
```json theme={"dark"}
{
"method": "POST",
"inject_headers_json": {
"Authorization": "Bearer {{secret:api_full_access_key}}"
}
}
```
**Result:**
* GET requests use read-only key
* POST requests use full-access key
***
## Secret Resolution Order
When multiple environments and methods are involved, KnoxCall resolves secrets in this order:
1. **Method-specific** environment override
2. **Environment override** (if method not specified)
3. **Base route** configuration
### Example
```text theme={"dark"}
Request: POST to route in "staging" environment
Lookup order:
1. Check: staging → POST method config → secrets
2. If not found: staging → default config → secrets
3. If not found: base (production) → POST method config → secrets
4. If not found: base (production) → default config → secrets
```
***
## Template Syntax Rules
### ✅ Correct Syntax
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_key}}"
}
```
**Rules:**
* Double curly braces: `{{` and `}}`
* Prefix: `secret:`
* No spaces inside braces
* Lowercase with underscores for secret names
### ❌ Common Mistakes
```json theme={"dark"}
// ❌ Single braces
{
"Authorization": "Bearer {secret:stripe_key}"
}
// ❌ No prefix
{
"Authorization": "Bearer {{stripe_key}}"
}
// ❌ Wrong prefix
{
"Authorization": "Bearer {{env:stripe_key}}"
}
// ❌ Spaces inside
{
"Authorization": "Bearer {{ secret:stripe_key }}"
}
// ❌ Hyphens in name
{
"Authorization": "Bearer {{secret:stripe-key}}"
}
```
***
## Real-World Examples
### Example 1: Stripe Payments
**Secret:**
```text theme={"dark"}
Name: stripe_prod_key
Value: sk_live_EXAMPLE_abc123xyz789...
```
**Route config:**
```json theme={"dark"}
{
"inject_headers_json": {
"Authorization": "Bearer {{secret:stripe_prod_key}}",
"Content-Type": "application/json"
}
}
```
**Actual request sent to Stripe:**
```http theme={"dark"}
POST /v1/charges HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_live_EXAMPLE_abc123xyz789...
Content-Type: application/json
{"amount": 1000, "currency": "usd"}
```
### Example 2: SendGrid Email
**Secret:**
```text theme={"dark"}
Name: sendgrid_api_key
Value: SG.abc123xyz789...
```
**Route config:**
```json theme={"dark"}
{
"inject_headers_json": {
"Authorization": "Bearer {{secret:sendgrid_api_key}}",
"Content-Type": "application/json"
},
"inject_body_json": {
"personalizations": [{
"to": [{"email": "user@example.com"}]
}],
"from": {"email": "noreply@acme.com"},
"subject": "Welcome!",
"content": [{
"type": "text/html",
"value": "Welcome!
"
}]
}
}
```
### Example 3: Database Access
**Secret:**
```text theme={"dark"}
Name: postgres_connection_url
Value: postgres://user:pass@host:5432/db
```
**Route config:**
```json theme={"dark"}
{
"inject_body_json": {
"connection_string": "{{secret:postgres_connection_url}}",
"query": "SELECT * FROM users WHERE id = $1",
"params": [123]
}
}
```
### Example 4: PrintNode Printing
**Secret:**
```text theme={"dark"}
Name: printnode_api_key
Value: abc123xyz789...
```
**Route config:**
```json theme={"dark"}
{
"inject_headers_json": {
"Authorization": "Basic {{secret:printnode_api_key}}"
},
"inject_body_json": {
"printerId": 123,
"title": "Receipt",
"contentType": "pdf_uri",
"content": "https://example.com/receipt.pdf",
"source": "KnoxCall POS"
}
}
```
### Example 5: Multi-Service Orchestration
**Secrets:**
```text theme={"dark"}
stripe_key
sendgrid_key
twilio_key
```
**Route config:**
```json theme={"dark"}
{
"inject_headers_json": {
"X-Service": "orchestrator"
},
"inject_body_json": {
"actions": [
{
"service": "payment",
"api_key": "{{secret:stripe_key}}",
"action": "charge"
},
{
"service": "email",
"api_key": "{{secret:sendgrid_key}}",
"action": "send_receipt"
},
{
"service": "sms",
"api_key": "{{secret:twilio_key}}",
"action": "send_notification"
}
]
}
}
```
***
## Combining Secrets with Other Templates
You can combine secrets with the other supported template forms in a single body or header set:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret_id:550e8400-e29b-41d4-a716-446655440000}}",
"X-Legacy-Key": "{{secret:api_key}}",
"X-User-ID": "{{var:user_id}}"
}
```
**Supported placeholder forms:**
* `{{secret_id:UUID}}` - Inject a secret by its **UUID** (preferred — rename-safe)
* `{{secret:name}}` - Inject a secret by its **name** (legacy — breaks on rename)
* `{{var:QUERY_PARAM}}` - Substitute the value of an incoming query-string parameter
These are the only placeholder forms the template engine resolves. Tokens such as `{{header:name}}`, `{{env:name}}`, or `{{uuid}}` are **not** supported and are passed through to the upstream unchanged.
***
## Security Considerations
### What Gets Logged
**In KnoxCall logs:**
* Template syntax shown: `{{secret:stripe_key}}`
* Actual value: **Never logged**
**Example log entry:**
```json theme={"dark"}
{
"request_headers": {
"Authorization": "Bearer {{secret:stripe_key}}"
}
}
```
Plaintext value is not exposed in logs for security.
### Secret Transmission
**Flow:**
```text theme={"dark"}
1. Client makes request to KnoxCall
↓
2. KnoxCall receives request
↓
3. Loads route configuration
↓
4. Sees {{secret:stripe_key}} template
↓
5. Decrypts secret SERVER-SIDE
↓
6. Replaces template with plaintext
↓
7. Forwards request to backend
↓
8. Backend receives real API key
↓
9. Client NEVER sees the plaintext!
```
### Best Practices
✅ **Do:**
* Use secrets for all sensitive values
* Separate secrets per environment
* Rotate secrets regularly
* Use descriptive secret names
* Document what each secret is for
❌ **Don't:**
* Hardcode API keys in route configs
* Use production secrets in development
* Share secrets via email or chat
* Commit secrets to git
* Use generic names like "key" or "secret"
***
## Troubleshooting
### Secret Not Replacing
**Symptom:** Backend receives `{{secret:stripe_key}}` literally.
**Causes:**
1. Syntax error in template
2. Secret doesn't exist
3. Secret name typo
**Debug steps:**
1. Check template syntax: `{{secret:name}}`
2. Verify secret exists: Resources → Secrets
3. Check spelling matches exactly (case-sensitive)
4. Look at logs for error messages
### Wrong Value Injected
**Symptom:** Wrong API key being used.
**Causes:**
1. Using wrong environment
2. Wrong secret name
3. Secret not configured for environment
**Fix:**
1. Check which environment request is using
2. Verify environment override has correct secret
3. Check secret name spelling
### Secret Not Found Error
**Error:** `Secret 'api_key' not found`
**Solution:**
1. Go to Resources → Secrets
2. Check secret exists
3. Verify name matches template exactly
4. Check no typos: `api_key` vs `api_key_`
### Value Not Decrypting
**Symptom:** Encrypted value sent instead of plaintext.
**Cause:** KnoxCall internal error (very rare).
**Solution:**
1. Check KnoxCall status
2. Try creating new secret version
3. Contact support if persists
***
## Testing Secret Injection
### Test Endpoint
Use a test endpoint to verify secret injection:
1. **Create test route:**
* Target: `https://httpbin.org/anything`
* Method: POST
2. **Add secret in header:**
```json theme={"dark"}
{
"X-Test-Secret": "{{secret:test_api_key}}"
}
```
3. **Make request:**
```bash theme={"dark"}
# $TOKEN is a kc_… OAuth access token — see /api-reference/authentication
curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/anything \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: test-route"
```
4. **Check response:**
```json theme={"dark"}
{
"headers": {
"X-Test-Secret": "your-actual-secret-value-here"
}
}
```
✅ If you see actual value, injection works!
***
## Quick Reference
| Use Case | Template Syntax | Example |
| - | - | - |
| **Header** | `{{secret:name}}` | `"Authorization": "Bearer {{secret:api_key}}"` |
| **Body** | `{{secret:name}}` | `"api_key": "{{secret:api_key}}"` |
| **Multiple** | Multiple templates | `{{secret:key1}}` and `{{secret:key2}}` |
| **Environment** | Different secret per env | Production: `{{secret:prod_key}}` |
***
## Next Steps
How to create and manage secrets
Complete secrets guide
Environment-specific secrets
Auto-refreshing OAuth2 tokens
***
**Pro Tip:** Always test secret injection with a test endpoint (like httpbin.org) before using in production. This lets you verify the actual decrypted value is correct.
# Tenant KMS key rotation
Source: https://docs.knoxcall.com/essentials/tenant-kms/key-rotation
Rotate your tenant master key to re-wrap all new data under a fresh KMS-backed version. Understand the lifecycle of active, retired, and revoked versions.
# Tenant KMS Key Rotation
Rotation creates a new tenant master key version wrapped under your KMS key. New writes immediately use the new version; existing ciphertext stays readable because the retired version remains available for unwrap. A background rewrap job migrates existing data to the new version automatically.
## What happens during rotation
1. A new 32-byte master key is generated and wrapped using your KMS key → new `active` version
2. The previous `active` version is moved to `retired`
3. New encrypts (new secrets, new CA key versions, new DB passwords) use the new active version immediately
4. Existing ciphertext wrapped by the retired version continues to unwrap successfully
5. A background rewrap job is enqueued to re-encrypt all existing tenant data under the new active version
## When to rotate
* **Suspected compromise** of the old master key version
* **Scheduled compliance rotation** (annual, quarterly — check your policy)
* **Post-breach seal/unseal cycle** — rotate after any event where you were unsure of KMS security
* **KMS key rotation** on the cloud provider side — rotate the KnoxCall master key after rotating the underlying KMS key so new wraps use the new KMS key version
**Azure Key Vault: rotating the key in Azure does not, by itself, change what KnoxCall
uses.** Azure resolves the key version from the `kms_key_ref` URL, not from the
ciphertext — so a version-pinned reference (the only kind KnoxCall accepts) keeps
wrapping and unwrapping against the version it names, even after you create a new one.
This is deliberate: prior key versions stay enabled in Azure, so your existing data
remains readable.
To actually move to a new Azure key version, update `kms_key_ref` to the new versioned
URL (Settings → Security → Tenant Master Key, or `PUT /admin/tenant-kms`) and then rotate
the KnoxCall master key. Do **not** disable or destroy the old Azure key version until
the rewrap job has finished — until then, retired master key versions still need it.
AWS KMS and Google Cloud KMS behave differently and need no such step: their ciphertext
records the key version it was produced under, so unwrapping keeps working across a
provider-side rotation automatically.
## How to rotate
### Via admin UI
1. Go to **Settings → Security → Tenant Master Key → Customer KMS**
2. Click **Rotate key**
3. Confirm the dialog
### Via API
Rotation requires a **recent step-up verification within a 5-minute window**. Complete a fresh second-factor step-up with `"action_scope": "tenant.kms"` on the same admin session immediately before calling rotate: a passkey via `POST /auth/passkey/verify-step-up`, an authenticator-app code via `POST /auth/2fa/verify-step-up`, or a security key (or passkey) via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`. An emailed code does not satisfy this gate — the gate checks the server-side verification record, so no separate step-up header is sent on the rotate request itself. A verification made for another action does not satisfy it, and a missing one answers `403` with `"requires_step_up": true`, `"action_scope": "tenant.kms"` and `"allowed_methods": ["passkey", "2fa"]`.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms/rotate \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
Response:
```json theme={"dark"}
{
"rotated": true,
"new_version": 2,
"rewrap_lease_id": 42
}
```
`rewrap_lease_id` is the identifier for the background rewrap job described below. It is `null` if the rewrap lease failed to issue — the rotation itself still succeeded in that case; contact support to trigger the rewrap manually.
Rotation is **non-reversible**. Once the old version is retired, you cannot make it active again. You can still decrypt existing data, but new writes will use the new version. If you revoke the old version later, anything written under it becomes permanently unreadable. Cannot be called while the tenant is sealed — unseal first.
## Background rewrap
After rotation completes, KnoxCall immediately enqueues a `tenant_rewrap` background job. This job re-encrypts all existing tenant data under the new active master key version:
* Processes **100 rows per batch** (interleaved: 50 from `secret_environment_configs`, 50 from `integration_configs`)
* Batches run every **5 seconds**
* The `target_version` is pinned at job creation time — even if another rotation occurs mid-flight, all rows processed by this job land on the same version (prevents split-version state across a rotation boundary)
* Rows already on the target version are skipped and counted as complete
* The lease expires after **7 days** — if a persistent KMS error prevents rewrap from completing in that window, contact support
You can track rewrap progress via Automation → Agents in the admin UI. The old retired version remains available for decryption throughout the rewrap and beyond, until you explicitly revoke it.
## Audit trail
Every rotation is logged in the [critical audit chain](/monitoring/audit-logs#critical-audit-chain):
| Audit action | Meaning |
| - | - |
| `byok.kms.rotate` | Master key rotation initiated |
The log entry includes `new_version`, `wrap_method`, `rewrap_lease_id`, and the IP that triggered it.
## Key version lifecycle
```text theme={"dark"}
ACTIVE (v2) ← current wrapping
RETIRED (v1) ← still decryptable, no new wraps
↓ (if explicitly revoked)
REVOKED (v1) ← cryptographic erasure — data is permanently unreadable
```
Revoking a retired version is a destructive, permanent action. Use it only when you need to guarantee that data written under that version can never be recovered — for example, data destruction per GDPR right-to-erasure.
## Checking key versions
```bash theme={"dark"}
curl https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
The response `keys` array contains all versions:
```json theme={"dark"}
{
"config": { ... },
"keys": [
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"version": 2,
"wrap_method": "customer_kms",
"status": "active",
"created_at": "2026-05-25T10:00:00Z",
"retired_at": null
},
{
"id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e",
"version": 1,
"wrap_method": "customer_kms",
"status": "retired",
"created_at": "2026-01-01T00:00:00Z",
"retired_at": "2026-05-25T10:00:00Z"
}
]
}
```
What happens if KMS becomes unreachable after rotation
Version lifecycle and cryptographic erasure background
Rotate endpoint reference
View the rotation audit trail
# Tenant KMS (BYOK) overview
Source: https://docs.knoxcall.com/essentials/tenant-kms/overview
Bring your own KMS key from AWS, GCP, or Azure. KnoxCall wraps all tenant data under your key — you hold the kill switch.
# Tenant KMS (BYOK) Overview
By default every KnoxCall tenant's master key is wrapped by the platform's own `MASTER_KEY_B64`. That's strong isolation, but the wrapping key lives on KnoxCall's infrastructure.
**Tenant KMS** lets you go a step further: the wrapping key lives in **your** cloud account. KnoxCall is granted a narrow IAM permission to call `Encrypt` / `Decrypt` against that one key — nothing else. Revoke the grant and KnoxCall can no longer unwrap on the next cache miss. The tenant enters the [sealed state](/essentials/tenant-kms/sealed-state) on that next miss — up to one hour later, since the unwrapped master key is cached for one hour.
## How it differs from Operator KMS
There are three wrapping options:
| Wrap method | Who holds the wrapping key | Kill-switch location |
| - | - | - |
| `global` | KnoxCall (env var) | None — contact support |
| `operator_kms` | KnoxCall-managed KMS key | None — contact support |
| `customer_kms` (**BYOK**) | **Your** AWS / GCP / Azure account | **Your** IAM console |
BYOK is Customer KMS. The Tenant KMS dashboard manages it.
## When to use it
* **Regulatory requirement** — FedRAMP, HIPAA, HITRUST, EU data residency frameworks that require customer-controlled key material
* **Kill-switch requirement** — need to instantly revoke KnoxCall's access to tenant data without contacting support
* **Audit requirement** — need your own KMS audit log to record every unwrap call KnoxCall makes
BYOK tenants have a **5-minute agent session window**, not the standard 1 hour. Agent sessions renew more frequently so that a revoked KMS grant caps in-flight plaintext exposure at roughly 5 minutes. Plan for this if you run long-lived self-hosted proxy deployments. (This is the agent *session* TTL — the in-process master-key cache TTL is 1 hour for all tenants.)
## Architecture
KMS is not in the path of every encrypt/decrypt — only on a cache miss:
```text theme={"dark"}
Incoming request
↓
KnoxCall proxy needs tenant master key
↓
Cache HIT → use cached key (valid for 1 hour)
Cache MISS → call customer KMS via IAM → unwrap master key → cache for 1 hour
↓
Encrypt / decrypt with master key (local, fast)
```
This means one KMS round-trip per 1-hour window, not per request.
### Key version states
| State | Meaning |
| - | - |
| `active` | Current version. New wraps use this. One per tenant at a time. |
| `retired` | Older version — still unwraps legacy ciphertext, no new wraps. |
| `revoked` | Cryptographic erasure. Data wrapped by this version is permanently unreadable. |
## Prerequisites
* **Owner** or **Admin** role on the tenant
* Provider IAM set up before onboarding — KnoxCall validates the grant at onboarding time
Static long-lived credentials (AWS access keys, GCP service account JSON key files) are **rejected at the API level**. AWS uses cross-account STS role assumption; GCP uses service account impersonation; Azure uses a client secret env var or Managed Identity. No credential value is ever stored in the database.
Onboarding (`POST /admin/tenant-kms`) does **not** require a step-up verification. Step-up is only required for sensitive post-onboard operations: unseal (2-minute window), rotate (5-minute window) and revert (2-minute window). Each needs a second-factor verification made with `"action_scope": "tenant.kms"`; an emailed code does not count.
## Quick start
Before saving, KnoxCall runs a **probe**: wrap then unwrap 32 test bytes against your KMS key. If the IAM grant isn't working, the request fails immediately and nothing is written to the database. Calling this endpoint when a KMS config already exists **replaces it** (UPSERT) — the probe runs first; if it fails, the existing config is unchanged.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-...",
"config": {
"region": "us-east-1",
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess",
"external_id": "your-chosen-external-id"
}
}'
```
For the full setup flow for each provider, see the pages below.
## Cache architecture
The master key is cached **in-process on the KnoxCall control plane** — not per-agent and not in Redis. Cache key: `:active`, TTL 1 hour (`DEFAULT_CACHE_TTL_MS`) for all tenants. Concurrent cache misses for the same tenant are deduplicated: a single KMS call is made and the result is shared to all waiters. This means KnoxCall makes roughly one KMS call per 1-hour window per tenant, regardless of agent count.
## Sealed state
If KnoxCall can't reach your KMS (IAM revoked, network partition, key disabled), the tenant enters the **sealed state**. New agent sessions fail with `503 Service Unavailable`. In-flight agents with a valid session continue operating until their session expires (at most 5 minutes for BYOK tenants). See [Sealed state and unseal](/essentials/tenant-kms/sealed-state) for recovery steps.
Cross-account IAM role + STS assumption
Service account impersonation — no JSON key files
Managed Identity or Federated Credentials
What happens when KMS is unreachable, and how to recover
Rotate your tenant master key under a new KMS-backed version
Background on global / operator\_kms / customer\_kms wrapping options
# Sealed state and unseal
Source: https://docs.knoxcall.com/essentials/tenant-kms/sealed-state
What happens when KnoxCall can't reach your KMS, and how to restore service. The sealed state is reversible — revocation is not.
# Sealed State and Unseal
When KnoxCall can't unwrap the tenant master key from your KMS, the tenant enters the **sealed state**. New agent sessions cannot be issued until access is restored.
This is a safety mechanism, not a failure. It guarantees that your kill switch works: if you revoke KnoxCall's IAM access, the tenant seals within one cache TTL.
## What triggers sealing
* IAM permission revoked (your kill switch in action)
* KMS key disabled or scheduled for deletion
* Network partition between KnoxCall and your KMS endpoint
* KMS service outage in your cloud provider's region
* KMS key version revoked (this triggers **permanent** sealing — see below)
## What happens when sealed
Sealing is enforced at the **agent session layer**, not at individual proxy requests:
* **Session endpoint**: `POST /agent/v1/session` (the endpoint agents call to obtain a session bundle) returns `503 Service Unavailable` with `{ "error": { "type": "tenant_sealed", "message": "..." } }`
* **In-flight agents**: agents that already hold a valid session continue to operate normally until their session expires — at most **5 minutes** for BYOK tenants
* **After session expiry**: agents must renew; renewal fails with 503 until the tenant is unsealed
* **Admin UI**: a sealed banner appears on the Customer KMS page (Settings → Security → Tenant Master Key → Customer KMS) with the last KMS error message
* **KMS config row**: `sealed_since` timestamp set; visible in `GET /admin/tenant-kms`. No audit action is written for the seal event itself — check `last_verify_error` for the reason
There is no per-request `423 Locked` response at the proxy layer. The sealed check happens once per session renewal. An agent mid-request is unaffected until its current session TTL runs out.
## Sealed vs revoked — a critical distinction
| | Sealed | Revoked |
| - | - | - |
| Trigger | KMS unreachable or IAM revoked | Tenant master key version explicitly revoked in KnoxCall |
| Reversible? | ✅ Yes — restore KMS access | ❌ No — permanent cryptographic erasure |
| Data state | Intact at rest, temporarily inaccessible | Data wrapped by that version is permanently unreadable |
| Recovery | Restore IAM + unseal | None |
Revoking the master key in KnoxCall is different from revoking IAM access. If you want a recoverable kill switch, revoke the IAM grant. If you want data destruction, revoke the master key in KnoxCall.
## Monitoring for seal events
**Seal state**: Sealing records `sealed_since` and `last_verify_error` on the tenant's KMS config row. There is no `byok.kms.seal` audit action — the seal itself is not logged in the audit chain. Check `GET /admin/tenant-kms` and look for a non-null `sealed_since` field.
**Unseal audit log**: When the tenant unseals (manually or automatically), a `byok.kms.unseal` critical audit entry is written. Filter by that action in Monitoring → Audit Logs to see the history of seal/unseal cycles.
**Alerts**: There is no dedicated seal webhook event, so monitor by polling `GET /admin/tenant-kms`: if `sealed_since` becomes non-null, page on-call immediately — new agent sessions will fail within the next 5 minutes.
## Unsealing
### Automatic unseal
Once you restore KMS access (re-grant the IAM permission, re-enable the key, resolve the network partition), the tenant clears its sealed flag **lazily, on the next successful unwrap** — no manual action required. There is no background poller: the sealed flag is cleared the next time KnoxCall has to unwrap that tenant's master key (the next cache miss), which is driven by agent traffic rather than a fixed timer. If no traffic arrives, use the manual unseal endpoint below to force it immediately.
The `byok.kms.unseal` action is logged when the tenant comes back up.
### Manual unseal via API
If you need to force an immediate unseal attempt (rather than waiting for the next unwrap), call the unseal endpoint. This is an admin (control-plane) endpoint: it's served on the admin host, authenticated with your session JWT plus the `X-Tenant-ID` header, and requires the owner/admin role. It also requires a **recent step-up verification within a 2-minute window**:
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms/unseal \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
The step-up requirement is enforced server-side against your account, not via a header on this request. Within the 2 minutes before calling unseal, complete a second-factor step-up on the same session with `"action_scope": "tenant.kms"`: `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "tenant.kms"}`, or a WebAuthn step-up with the same scope (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key). An emailed code does not count, and a verification made for another action, such as one an unrelated Dashboard prompt raised, carries that action's scope and is refused here. The window is intentionally tight. The Dashboard's own **Unseal** button raises the right verification for you. If no matching verification is on file, the endpoint returns `403` with `"requires_step_up": true`, `"action_scope": "tenant.kms"` and `"allowed_methods": ["passkey", "2fa"]`.
This endpoint is not idempotent: calling it when the tenant is already unsealed returns `409 not_sealed`.
Response on success:
```json theme={"dark"}
{ "unsealed": true }
```
If the KMS is still unreachable or access is still denied, the response is `502 unseal_failed` with a `reason` field:
```json theme={"dark"}
{
"error": "unseal_failed",
"reason": "access_denied",
"message": "..."
}
```
`reason` is either `"access_denied"` (IAM grant still missing or invalid) or `"unreachable"` (network / KMS service issue).
### Unseal via admin UI
1. Go to **Settings → Security → Tenant Master Key → Customer KMS**
2. The sealed banner shows the last KMS error
3. After restoring KMS access, click **Unseal**
## Troubleshooting
**Unseal stuck, KMS access looks correct:**
* Verify the IAM role still exists and the trust policy is intact: `aws iam get-role --role-name KnoxCallKMSAccess`
* For GCP: verify KnoxCall's principal still has `roles/iam.serviceAccountTokenCreator` on your service account — run `gcloud iam service-accounts get-iam-policy knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com`
* For Azure: check the `Key Vault Crypto User` role assignment still exists on the vault
**`502 unseal_failed` with `reason: "access_denied"`:**
* The IAM/RBAC grant was removed or expired — restore it and retry immediately
* For Azure: confirm the client secret env var is still set on the KnoxCall server process and hasn't rotated
**`409 not_sealed` on the unseal endpoint:**
The tenant is not currently sealed. No action needed — the tenant is operating normally.
**Tenant unseals but reseals again after a few minutes:**
* The IAM grant may have been partially restored — check that both wrap and unwrap operations are permitted (AWS: `kms:Encrypt` and `kms:Decrypt`; Azure: `wrapKey` and `unwrapKey`)
* KMS key rotation in your cloud provider may have invalidated the key reference stored in KnoxCall — check `GET /admin/tenant-kms` for `last_verify_error`
**KMS access cannot be restored (key deleted, account closed, provider off-boarded):**
As a last resort, [revert to a KnoxCall-managed key](/api-reference/tenant-kms/revert)
(**Settings → Security → Customer KMS → Revert to operator key**). If your KMS is
still reachable the revert is lossless — everything is re-encrypted first. If it
is not, you must explicitly acknowledge that secrets written while BYOK was
active become unreadable and re-enter their values afterwards.
Rotate your tenant master key to a new version
Unseal and rotate endpoint reference
Re-check your IAM setup
Find seal and unseal events in the audit trail
# Tenant KMS: AWS KMS setup
Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-aws
Configure AWS KMS as your tenant's wrapping key provider using cross-account IAM role assumption. No static credentials accepted.
This guide walks you through creating an AWS KMS key, granting KnoxCall cross-account access via an IAM role, and onboarding it as your tenant's wrapping key.
## Prerequisites
* AWS account with permission to create KMS keys and IAM roles
* KnoxCall **Owner** or **Admin** role
Static credentials (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are **rejected at the API level** — not just discouraged. Cross-account role assumption via `role_arn` + `external_id` is the only accepted authentication method.
## Step 1: Create a symmetric KMS key
In your AWS account, create a new symmetric KMS key:
```bash theme={"dark"}
aws kms create-key \
--description "KnoxCall tenant master key wrapping" \
--key-usage ENCRYPT_DECRYPT \
--key-spec SYMMETRIC_DEFAULT \
--region us-east-1
# Note the KeyId and Arn in the response
```
Optionally add an alias:
```bash theme={"dark"}
aws kms create-alias \
--alias-name alias/knoxcall-tenant-wrapping \
--target-key-id
```
## Step 2: Create a cross-account IAM role
KnoxCall assumes a role in your account using STS. Create an IAM role with the following trust policy — replace `` with KnoxCall's AWS account ID, shown in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, AWS step). The wizard also renders this exact trust policy ready to paste, with the account ID and your external ID already substituted.
If you prefer the API, the same value is available at:
```bash theme={"dark"}
curl https://admin.knoxcall.com/admin/tenant-kms/provider-identity \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
# → { "aws": { "configured": true, "account_id": "...", "principal_arn": "..." }, "gcp": { ... } }
```
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam:::root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": ""
}
}
}]
}
```
**Do not choose this value yourself.** KnoxCall issues it, and it is shown in
**Settings → Security → Tenant Master Key** (format `knoxcall::`).
Copy it verbatim into the condition above.
This is what makes the external ID a confused-deputy defence. The `Principal` above is
KnoxCall's whole AWS account, so the external ID is the only thing distinguishing your
role from any other customer's. If customers picked their own, one tenant could submit
another tenant's role ARN together with that tenant's external ID — a value that lives
in Terraform state, CI logs and support tickets, not in a vault — and have KnoxCall
assume it on their behalf. Onboarding rejects any value other than the one we issued.
Attach this inline permission policy to the role, scoped to only the one KMS key:
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:Decrypt"],
"Resource": ""
}]
}
```
Note the role's ARN — you'll need it in the next step.
## Step 3: Onboard via admin UI
1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK**
2. Select **AWS KMS** and click **Next**
3. Fill in:
* **KMS key ARN** — the full `arn:aws:kms:...` ARN (or alias ARN)
* **Region** — the AWS region where your KMS key lives
* **Cross-account role ARN** — the role you created in Step 2
* **External ID** — the UUID from Step 2
4. Click **Next** to review, then **Confirm + activate**
KnoxCall performs a probe encrypt + decrypt round-trip before committing. If verification fails, the change is not applied.
## Step 4: Onboard via API
No step-up verification is required for onboarding. KnoxCall runs a probe (wrap + unwrap on 32 test bytes) before writing anything — if the IAM grant isn't working, the request fails immediately with `400` and a plain message like `KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.` (or `KMS probe failed.` for other probe errors). If KMS itself is unreachable, you get `502` with `KMS provider unreachable during probe.` instead.
```bash theme={"dark"}
curl -X POST https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-5678-efgh",
"config": {
"region": "us-east-1",
"role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess",
"external_id": "550e8400-e29b-41d4-a716-446655440000"
}
}'
```
Note: `provider` is `"aws"` (not `"aws_kms"`). A successful response:
```json theme={"dark"}
{
"ok": true,
"provider": "aws",
"kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-5678-efgh",
"new_key_version": 1,
"new_key_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"rewrap_status": "enqueued",
"note": "A background rewrap pass will re-encrypt existing tenant data under the new key. Existing in-flight agent sessions remain valid for up to 5 minutes (BYOK session TTL)."
}
```
`rewrap_status` is always `"enqueued"` — KnoxCall unconditionally issues a background rewrap lease after onboarding.
## Step 5: Verify the configuration
```bash theme={"dark"}
curl https://admin.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "X-Tenant-ID: $KC_TENANT_ID"
```
Confirm `config.sealed_since` is `null` and the `keys` array contains one active version.
## Session duration impact
Once BYOK is active, all agent sessions for this tenant have a **5-minute TTL** instead of the default 1 hour. Self-hosted proxy agents will renew sessions more frequently — this is intentional to bound plaintext exposure if you later revoke KMS access. Ensure your agents are not configured with session renewal intervals longer than 4 minutes.
## Revoking access (the kill switch)
To lock KnoxCall out of your tenant data:
1. Remove (or deny) the `kms:Decrypt` permission from the IAM role — or detach the inline policy entirely.
2. On the next unwrap cache miss — within one cache TTL (1 hour) — KnoxCall will fail to unwrap and the tenant will enter the [sealed state](/essentials/tenant-kms/sealed-state). New agent sessions stop being issued sooner: because BYOK caps session TTL at 5 minutes, no fresh plaintext exposure can be granted beyond that window.
This is reversible — restore the IAM permission and KnoxCall will unseal automatically on the next successful unwrap.
To **permanently destroy** the data, revoke the tenant master key in KnoxCall itself (cryptographic erasure). The KMS key in your account becomes irrelevant — the wrapped key material is gone.
## Troubleshooting
**`AccessDenied` during onboarding verify:**
* Check the trust policy includes the correct KnoxCall AWS account ID (shown in the BYOK setup wizard's AWS step)
* Verify `sts:ExternalId` in the trust policy matches what you entered exactly
* Confirm the inline policy ARN matches the KMS key ARN (no typos)
**`NoCredentialProviders` error:**
* The KnoxCall principal ARN must appear in the trust policy's `Principal.AWS` field
* Ensure you saved the role after adding the trust policy
**Role ARN mismatch:**
* Copy the role ARN directly from the IAM console — avoid hand-typing the account ID
**Verification times out:**
* Check KMS key is not in a pending-deletion state: `aws kms describe-key --key-id `
* Verify the KMS key is in the correct region
Service account impersonation for GCP
What to do when KMS becomes unreachable
Rotate to a new master key version
Full endpoint reference
# Tenant KMS: Azure Key Vault setup
Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-azure
Configure Azure Key Vault as your tenant wrapping key provider using a client secret env var or Managed Identity. Client secret values are never stored in the database.
This guide walks you through creating an Azure Key Vault and key, granting KnoxCall access, and onboarding it as your tenant's wrapping key.
Two authentication paths are available:
| Option | When to use |
| - | - |
| **App Registration + client secret env var** | Cloud-hosted KnoxCall (recommended for most tenants) |
| **Managed Identity** | Self-hosted KnoxCall on an Azure VM, App Service, or AKS |
## Prerequisites
* Azure subscription with permission to create Key Vaults, App registrations, and RBAC role assignments
* KnoxCall **Owner** or **Admin** role
The `client_secret_env_var` field accepts the **name** of an environment variable set on the KnoxCall server — the actual secret value is read at runtime and is **never stored in the database**. This is enforced server-side. Passing a raw client secret value as the field value is rejected.
***
## Step 1: Create an Azure Key Vault and key
```bash theme={"dark"}
az keyvault create \
--name my-knoxcall-kv \
--resource-group my-rg \
--location eastus \
--sku premium \
--enable-rbac-authorization true
az keyvault key create \
--vault-name my-knoxcall-kv \
--name tenant-wrapping-key \
--kty RSA \
--size 2048 \
--ops wrapKey unwrapKey
```
Note the vault key URL — you'll use it as `kms_key_ref`. **It must include the key
version** (the trailing identifier). `az keyvault key create` prints it as `kid`:
```text theme={"dark"}
https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c
```
A **versionless** URL (`.../keys/tenant-wrapping-key`, with no trailing version) is
rejected. Azure resolves the key version from the URL rather than from the ciphertext,
so a versionless reference always unwraps against whatever version the vault currently
considers latest. The first time you rotated the key in Azure, every master key wrapped
under the old version would become permanently unreadable — including retired versions,
and therefore all data still encrypted under them.
Pinning the version is safe under rotation: Azure keeps prior key versions enabled, so
the pinned version keeps unwrapping. Moving to a new version is a deliberate step — see
[Key rotation](/essentials/tenant-kms/key-rotation).
Use the RBAC authorization model (`--enable-rbac-authorization true`), not access policies. This is required for the Key Vault Crypto User role to work.
***
## Option A: App Registration + client secret env var
This option works for any deployment. The App Registration acts as KnoxCall's identity in your Azure AD tenant.
### Step 2: Create an App Registration
In the Azure portal, go to **Azure Active Directory → App registrations → New registration**:
* **Name**: `KnoxCall Tenant KMS`
* **Supported account types**: Single tenant
* No redirect URI needed
Note the **Application (client) ID** and **Directory (tenant) ID**.
### Step 3: Create a client secret
In the App Registration, go to **Certificates & secrets → Client secrets → New client secret**:
* **Description**: `KnoxCall BYOK`
* **Expires**: 24 months (or per your policy)
Note the secret **value** immediately — you'll set it as a server-side environment variable. It won't be shown again.
Set the secret on the KnoxCall server environment:
```bash theme={"dark"}
# On the KnoxCall server / container
export AZURE_KC_CLIENT_SECRET=""
```
The value of `client_secret_env_var` you pass to the API is the **variable name** (`AZURE_KC_CLIENT_SECRET`), not the secret itself.
### Step 4: Grant access to the key
Assign the **Key Vault Crypto User** role to the App Registration on the key vault:
```bash theme={"dark"}
az role assignment create \
--role "Key Vault Crypto User" \
--assignee \
--scope /subscriptions//resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-knoxcall-kv
```
### Step 5: Onboard via admin UI
1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK**
2. Select **Azure Key Vault** and click **Next**
3. Fill in:
* **Key Vault key URL** — `https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c`
* **Vault URL** — `https://my-knoxcall-kv.vault.azure.net`
* **Entra tenant ID** — your Directory (tenant) ID
* **Client ID** — the Application (client) ID
4. Click **Next** to review, then **Confirm + activate**
### Step 6: Onboard via API
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "azure",
"kms_key_ref": "https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c",
"config": {
"vault_url": "https://my-knoxcall-kv.vault.azure.net",
"azure_tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh",
"client_id": "11112222-3333-4444-5555-666677778888",
"client_secret_env_var": "AZURE_KC_CLIENT_SECRET"
}
}'
```
Note: `provider` is `"azure"` (not `"azure_key_vault"`). There is no `key_name` in `config` — the key name is part of `kms_key_ref`.
***
## Option B: Managed Identity (for self-hosted KnoxCall on Azure)
If you run a [self-hosted KnoxCall](/infrastructure/self-hosted-proxy) on an Azure VM, App Service, AKS, or Container App with a system-assigned or user-assigned Managed Identity, you can grant that identity direct access to the vault.
### Step 2 (Option B): Grant Managed Identity access
Find your Managed Identity's object ID and assign **Key Vault Crypto User**:
```bash theme={"dark"}
az role assignment create \
--role "Key Vault Crypto User" \
--assignee-object-id \
--assignee-principal-type ServicePrincipal \
--scope /subscriptions//resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-knoxcall-kv
```
### Step 3 (Option B): Onboard via API
When `client_secret_env_var` is omitted and no explicit client credentials are provided, KnoxCall uses `DefaultAzureCredential` which picks up the Managed Identity automatically via the Azure IMDS endpoint:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "azure",
"kms_key_ref": "https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c",
"config": {
"vault_url": "https://my-knoxcall-kv.vault.azure.net"
}
}'
```
`azure_tenant_id`, `client_id`, and `client_secret_env_var` are all omitted — the Managed Identity is detected at runtime.
***
## Verify the configuration
```bash theme={"dark"}
curl https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
Confirm `config.sealed_since` is `null` and the `keys` array contains one active version.
## Session duration impact
BYOK tenants have a **5-minute session TTL** (vs 1 hour for standard tenants). Self-hosted proxy agents renew sessions every 4 minutes. Ensure your infrastructure does not block frequent outbound HTTPS to `api.knoxcall.com`.
## Revoking access
To lock KnoxCall out within one cache TTL (5 minutes):
1. Remove the **Key Vault Crypto User** role assignment from the App Registration or Managed Identity, **or**
2. Disable or delete the key in Key Vault
Within 5 minutes, the tenant enters the [sealed state](/essentials/tenant-kms/sealed-state). No data is lost — you can unseal at any time by restoring the role assignment.
## Troubleshooting
**`Forbidden` / `Authorization failed` during verify:**
* Confirm the App Registration or Managed Identity has `Key Vault Crypto User` on the **vault** (not just a key-level grant)
* Check the vault has RBAC authorization enabled: `az keyvault show --name my-kv --query properties.enableRbacAuthorization`
**`client_secret_env_var` not found:**
* The environment variable is not set on the KnoxCall server process
* The env var name is case-sensitive — `AZURE_KC_CLIENT_SECRET` ≠ `azure_kc_client_secret`
**Managed Identity not detected:**
* Managed Identity must be enabled on the Azure resource running KnoxCall (VM → Identity → On, or `--assign-identity` flag)
* `DefaultAzureCredential` queries the IMDS endpoint at `http://169.254.169.254` — ensure no firewall blocks it
**`InvalidKeySize` or key operation error:**
* Key Vault requires RSA 2048+ for wrap/unwrap operations
* Ensure the key was created with `--ops wrapKey unwrapKey` (not `encrypt decrypt` — Azure uses different operations for RSA keys)
Cross-account IAM role setup for AWS
Service account impersonation for GCP
What to do when Key Vault becomes unreachable
Full endpoint reference
# Tenant KMS: GCP Cloud KMS setup
Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-gcp
Configure GCP Cloud KMS as your tenant wrapping key provider. KnoxCall impersonates a service account you designate — no JSON key files accepted.
This guide walks you through creating a GCP Cloud KMS key, creating a service account with the required permissions, allowing KnoxCall to impersonate that service account, and onboarding it as your tenant's wrapping key.
## How GCP authentication works
KnoxCall uses its own GCP credentials (configured on the KnoxCall control plane) to impersonate a **service account you create** in your GCP project. That service account is granted `roles/cloudkms.cryptoKeyEncrypterDecrypter` on your key. You never give KnoxCall your service account's key — only the right to impersonate it.
```text theme={"dark"}
KnoxCall control plane
→ impersonates knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com
→ calls GCP Cloud KMS encrypt/decrypt on your key
```
## Prerequisites
* GCP project with billing enabled
* Permission to create KMS key rings, keys, service accounts, and IAM bindings
* KnoxCall **Owner** or **Admin** role
Service account JSON key files are **rejected at the API level**. The only accepted credential model is service account impersonation granted to KnoxCall's own GCP principal. This is enforced server-side — not just a recommendation.
## Step 1: Enable the Cloud KMS API and create a key
```bash theme={"dark"}
# Enable the API
gcloud services enable cloudkms.googleapis.com --project=YOUR_PROJECT
# Create a key ring
gcloud kms keyrings create knoxcall-keys \
--location=global \
--project=YOUR_PROJECT
# Create a symmetric encryption key
gcloud kms keys create tenant-master-key \
--keyring=knoxcall-keys \
--location=global \
--purpose=encryption \
--project=YOUR_PROJECT
```
Note the full key resource name — you'll need it in Step 6:
```text theme={"dark"}
projects/YOUR_PROJECT/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key
```
## Step 2: Create a service account for KnoxCall
Create a dedicated service account in your project that KnoxCall will impersonate:
```bash theme={"dark"}
gcloud iam service-accounts create knoxcall-kms-sa \
--display-name="KnoxCall KMS access" \
--project=YOUR_PROJECT
```
This creates `knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com`.
## Step 3: Grant the service account KMS permissions
Grant `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the specific key (not the project or key ring — follow least privilege):
```bash theme={"dark"}
gcloud kms keys add-iam-policy-binding tenant-master-key \
--keyring=knoxcall-keys \
--location=global \
--project=YOUR_PROJECT \
--member="serviceAccount:knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/cloudkms.cryptoKeyEncrypterDecrypter"
```
## Step 4: Allow KnoxCall to impersonate the service account
KnoxCall's GCP principal needs `roles/iam.serviceAccountTokenCreator` on the service account you created. Find KnoxCall's GCP principal email in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, GCP step — it also shows this exact `gcloud` command pre-filled), or fetch it from `GET /admin/tenant-kms/provider-identity`, and run:
```bash theme={"dark"}
gcloud iam service-accounts add-iam-policy-binding \
knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com \
--member="serviceAccount:" \
--role="roles/iam.serviceAccountTokenCreator" \
--project=YOUR_PROJECT
```
Replace `` with the email shown in the admin UI.
## Step 5: Onboard via admin UI
1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK**
2. Select **GCP Cloud KMS** and click **Next**
3. Fill in:
* **Cloud KMS key resource name** — the full `projects/.../cryptoKeys/...` path from Step 1
* **Service account to impersonate** — `knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com`
* **Project ID** — `YOUR_PROJECT` (optional — inferred from the service account if omitted)
4. Click **Next** to review, then **Confirm + activate**
KnoxCall runs a wrap + unwrap round-trip before saving — if the probe fails, nothing is persisted.
## Step 6: Onboard via API
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider": "gcp",
"kms_key_ref": "projects/YOUR_PROJECT/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key",
"config": {
"impersonate_service_account": "knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com",
"project_id": "YOUR_PROJECT"
}
}'
```
Note: `provider` is `"gcp"` (not `"gcp_cloud_kms"`). The `kms_key_ref` contains the full GCP resource path — the `config` only contains authentication references.
## Step 7: Verify the configuration
```bash theme={"dark"}
curl https://api.knoxcall.com/admin/tenant-kms \
-H "Authorization: Bearer $KC_ADMIN_JWT"
```
Confirm `config.sealed_since` is `null` and the `keys` array contains one active version.
## Session duration impact
BYOK tenants have a **5-minute session TTL** (vs 1 hour for standard tenants). Self-hosted proxy agents renew sessions every 4 minutes. Ensure your infrastructure does not block frequent outbound HTTPS to `api.knoxcall.com`.
## Revoking access
To lock KnoxCall out within one cache TTL (5 minutes):
1. Remove the `roles/cloudkms.cryptoKeyEncrypterDecrypter` binding from the service account, **or**
2. Remove the `roles/iam.serviceAccountTokenCreator` binding that allows KnoxCall to impersonate the SA
Within 5 minutes, the tenant enters the [sealed state](/essentials/tenant-kms/sealed-state). The KnoxCall control plane detects the IAM error and records the `sealed_since` timestamp. No data is lost — you can unseal at any time by restoring the IAM grant.
## Troubleshooting
**`PERMISSION_DENIED` during verify:**
* Confirm `knoxcall-kms-sa` has `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the **key** specifically (not just the key ring or project)
* Confirm KnoxCall's GCP principal has `roles/iam.serviceAccountTokenCreator` on `knoxcall-kms-sa`
* Verify the service account email in `impersonate_service_account` is spelled correctly (case-sensitive)
**`Error creating impersonated credentials`:**
* The `roles/iam.serviceAccountTokenCreator` binding is missing or applied at the wrong resource
* Run: `gcloud iam service-accounts get-iam-policy knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com` and verify KnoxCall's principal appears
**Key ring or key not found:**
* Ensure the location in `kms_key_ref` exactly matches the location used when creating the key ring (`global` is not the same as `us-central1`)
* The full resource path is case-sensitive
**Probe passes but tenant seals after onboarding:**
* The service account's impersonation grant expires or is revoked by a GCP policy
* Check IAM audit logs in Cloud Console for `GenerateAccessToken` denials
Cross-account IAM role setup for AWS
Managed Identity or Federated Credentials for Azure
What to do when KMS becomes unreachable
Full endpoint reference
# Vaults overview
Source: https://docs.knoxcall.com/essentials/vaults/overview
Tokenize sensitive values (SSNs, emails, bank details, any PII) so your databases store opaque tokens instead of raw data. KnoxCall holds the values behind a per-vault encryption key.
# Vaults overview
KnoxCall's **Vaults** feature is a **data tokenization service**. You exchange a sensitive value (an SSN, a bank account, an email, an arbitrary blob) for a **token** — a shorter, opaque string. Your databases and analytics pipelines store the token. Whenever you need the original value, you call KnoxCall's detokenize endpoint.
The Vault parallel: this is what HashiCorp calls a *Tokenization Engine*.
**Vaults are not a payment-card compliance control.** KnoxCall holds no PCI DSS
attestation — no AOC, no ROC, no QSA engagement — so putting card numbers in a
vault does not move your cardholder-data scope; it just moves where the numbers
are stored. If scope reduction is what you need, use an assessed card vault.
Our current status is on the [trust page](https://knoxcall.com/trust).
Use it for:
* **Keeping sensitive values out of your own datastores** — your DB, logs and analytics extracts hold tokens; KnoxCall holds the values behind a per-vault key.
* **PII lifecycle controls** — set TTLs so emails / SSNs auto-expire; delete one token for cryptographic erasure of that value alone, or destroy a vault to erase everything in it.
* **Format preservation** — tokens that pass the same format checks as the originals (NNN-NN-NNNN for SSNs, a fixed digit shape for card-shaped columns), so existing systems keep working without schema changes.
* **Centralised audit** — **every** detokenize writes an audit row naming the vault, the token, the key version and the user or key that read it. Every release path writes the same row: a plain read on the API or the dashboard, detokenization *inside* a `/v1/proxy` request (a template referencing three tokens writes three rows, not one row carrying a count), and a Route `detokenize` field action, which until 2026-09-15 released values with no row at all. The row is anchored on the **vault** (`resource_id`), carries the token in `details.token_id`, and names the destination host when the value was sent somewhere — so "where did this vault's data go, and when?" is one query. A detokenize that is **refused** is recorded too, as `vault.detokenize_refused` with the reason, because a credential repeatedly being turned away from your card vault is the thing you most want to see.
## Why use it
| Problem | Without KnoxCall | With KnoxCall |
| - | - | - |
| Sensitive values scattered across 14 microservices | Every service holds the real value | Only the gateway resolves values; everything downstream holds tokens |
| You need to make a dataset unrecoverable | Hunt every DB + backup | Delete one token → its own data key is destroyed, so that value alone becomes unopenable. Destroy the vault → the same for every token in it |
| Email leak from analytics warehouse | Real emails leak | Tokenized emails leak — domain still useful for routing/cohorts |
| Format-preserving = build it yourself | Hand-rolled FPE = subtle bugs | Built-in `pan` (Luhn-valid), `ssn` (9XX prefix avoids real SSNs), `email`, `generic` |
## How it works
1. **Create a vault** with a chosen [token format](#token-formats). The format is locked at creation — changing it later would invalidate every existing token.
2. **Tokenize** values: `POST /v1/vaults/{name}/tokens` with `{ value, metadata?, ttl_seconds? }`. KnoxCall returns a token string + an internal id. Store the token in your DB.
3. **Detokenize** when you need the original: `GET /v1/vaults/{name}/tokens/{token}`. Audited every time.
4. **Rotate** the vault's encryption key when you want to bump versions. Existing tokens still decrypt under their original version; new tokens encrypt under the new version. No re-encryption of stored data needed.
5. **Destroy** a vault to cryptographically shred every token in it. Permanently unreadable.
## Quick start (UI)
1. Go to **Protect → Vaults** in the admin UI and click **New Vault**.
2. Pick a **token format**:
* `generic` — opaque token, safe for any value type. Default.
* `pan` — credit card numbers (16 digits, Luhn-valid, BIN + last4 preserved).
* `ssn` — US SSNs (9XX-XX-XXXX, last 4 preserved).
* `email` — local-part tokenised, domain preserved.
3. Optional: set a **default TTL** so tokens auto-expire.
4. Save. Use the **Playground** tab to tokenize / detokenize a few values.
## Quick start (API)
All responses use the standard `{ data: ..., meta: { request_id: "..." } }` envelope. The examples below show the `data` fields.
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
# 1. Create a vault for credit card numbers
curl -X POST https://api.knoxcall.com/v1/vaults \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "cards", "token_format": "pan", "allowed_destinations": ["api.stripe.com"] }'
# 2. Tokenize a card
curl -X POST https://api.knoxcall.com/v1/vaults/cards/tokens \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "value": "4111111111111111" }'
# → data: { "id": "3f1c8e2a-9b7d-4c6a-8e21-0d5f4a1b2c3d", "token": "4111110000091111", "expires_at": null, "created_at": "2026-01-20T10:00:00.000Z" }
# ^^^^^^ ^^^^
# BIN preserved last4 preserved
# 3. Detokenize (audited)
curl https://api.knoxcall.com/v1/vaults/cards/tokens/4111110000091111 \
-H "Authorization: Bearer $TOKEN"
# → data: { "id": "3f1c8e2a-9b7d-4c6a-8e21-0d5f4a1b2c3d", "token": "4111110000091111", "value": "4111111111111111",
# "value_b64": "NDExMTExMTExMTExMTExMQ==", "metadata": null,
# "expires_at": null, "created_at": "...", "crypto_key_version": 1 }
```
## Token formats
| Format | Shape | Preserves | Validates as | Best for |
| - | - | - | - | - |
| **generic** | `tok_` | nothing — opaque | nothing — opaque | Default. JSON blobs, free-text PII, API tokens. |
| **pan** | 16 digits | first 6 (BIN) + last 4 | Luhn-valid 16-digit number | Card-shaped DB columns that must keep validating. Not a compliance control — see the warning above. |
| **ssn** | NNN-NN-NNNN | last 4 | SSN-shape | US contractor / customer onboarding. Token always starts with 9 (SSA reserves 9XX, so guaranteed not to collide with real SSNs). |
| **email** | `@` | domain (lowercased) | RFC-shape email | Marketing/analytics that key on email domain but shouldn't see local-parts. |
The `pan` and `ssn` generators solve the math so the resulting token validates: PAN tokens pass the Luhn check, SSN tokens fit the regex your form already enforces. You drop the token into the same column the original would have gone in.
### `pan` vaults are destination-bound, and cannot be read in the clear
A `pan` vault is the one format with two extra rules, both enforced in the vault
service so no caller can route around them:
* **It requires a non-empty `allowed_destinations` list at creation**, naming the
hosts a card number may be sent to (e.g. `["api.stripe.com"]`). The list cannot
later be cleared. Creating one without it is refused.
* **It refuses the plain read.** `GET /v1/vaults/{vault}/tokens/{token}`, its
`/admin` twin and the browser capability consume all ask for a value with no
destination attached — and a `pan` vault answers all three with a refusal. The
number leaves only through **detokenize-and-send**, where KnoxCall knows the
host it is about to reach:
* `POST /v1/proxy` resolving a `{{ token: … }}` towards the URL in
`X-Knox-Proxy-URL`, or
* a **Route** with a request-direction `detokenize` field action, which names
that route's own resolved upstream host.
A destination not on the list is refused *before* anything is decrypted.
Moving a route that carries a `detokenize` action (either direction) to a new
upstream is an Owner or Admin act with a fresh second factor, or, on `/v1`, needs
`vaults:detokenize` — see
[Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes).
A Route's **response**-direction `detokenize` is still a plain read and is
still refused: the value there would be handed back to the caller, not sent to
a processor, which is the one thing a `pan` vault never does.
Absence of a destination is treated as a plain read rather than as "unknown", so
a future caller that forgets to say where the value is going gets the stricter
answer, not the weaker one.
### Card numbers only go in `pan` vaults
The rules above are what makes a `pan` vault different, and they are worth
nothing if a card number can simply be tokenized somewhere else. So it cannot
be: **a value that looks like a payment card number is refused by every vault
whose `token_format` is not `pan`**, with `400 wrong_format` and a message
naming the format to use. The refusal covers `value` and every string inside
`metadata` — a number in `metadata.card_number` is stored just as surely as one
in `value`.
It applies wherever a value is tokenized: `POST /v1/vaults/{vault}/tokens` and
its bulk twin, the `/admin` pair, a Route's `tokenize` field action, and the
browser capability consume behind a hosted field. In a **bulk** call the whole
batch is refused — nothing is stored for any value in it — and the message names
the index of the offending element.
A value is treated as a card number when it is 13-19 digits (spaces and hyphens
are ignored), passes the Luhn check, **and** begins in a range cards are
actually issued in. All three are required: a Luhn-valid sixteen-digit order
reference such as `7000000000000005` is not in an issued range and tokenizes
normally, and `4111111111111112` is in one but fails Luhn, so it tokenizes too.
We refuse rather than accept because a card number in a `generic` vault would
sit in KnoxCall's custody carrying none of the protections above — no
destination binding, no plain-read refusal, no format-preserving token — and
outside whatever boundary anyone later draws around card data. There is no flag
to turn this off: it is what keeps a tenant who never uses the card format out
of that boundary entirely.
### A processor that echoes the card number back
When a Route's **request**-direction `detokenize` opens a `pan` vault, the real
card number goes to the upstream on purpose — that is the whole point of
detokenize-and-send. Many processors then echo it straight back in their
confirmation object, in a `Location` redirect or in a plain-text receipt.
KnoxCall scans the reply for that exact number and puts the token back before
the response reaches you — and before it reaches the four other places that copy
a response: your outbound **webhooks**, the **workflow** event that Route
responses trigger, the S3 **response archive**, and the `response_body` field of
the request log. None of the five sees the card number. (The two that are also
log surfaces — `response_body` and the webhook payload — mask any card-shaped
value to its last four on top of this, so what you read there is
`••••••••••••1111` rather than the token itself.)
Scope is deliberately narrow and exact:
* **Only the values this request opened.** A different card number the processor
mentions is your own data and is left alone; this is not a general card scan.
* **Only `pan` vaults** — a `generic`, `ssn` or `email` value echoed back is
untouched.
* **However the processor spelled it** — spaces, dashes and the form-encoded
`+` / `%20` forms all match, in JSON string and number fields at any depth, in
object keys, in form and text bodies, and in header values.
Two consequences to plan for:
* A request that detokenizes a `pan` token is always answered **buffered**, even
if the caller asks for `text/event-stream`: a card number can straddle any
chunk boundary the upstream picks, so a streamed reply cannot be re-tokenized
honestly.
* If the reply cannot be read at all — an encoding KnoxCall does not decode, a
binary body, or a body past the response-log ceiling — the response is withheld
with `502 relay_response_not_retokenizable` rather than delivered unscanned.
**Do not blindly retry** — the upstream call already happened.
Each re-tokenized response writes a `vault.retokenize` audit entry naming the
vault and the occurrence count — never the number and never the token. A
non-zero count is worth acting on: it means your processor is echoing card data.
### KnoxCall alerts you when card data goes somewhere new
Every successful detokenize is counted against **where the value went** — the
destination pattern it matched on the vault's `allowed_destinations` list, or the
exact hostname when no pattern covers it. The counter holds the tenant, the
vault, the data space, that destination bucket, a five-minute window and a count,
and nothing else: never the token, never the value, never the last four digits.
Two things then raise an alert. The alert itself — **Vault Destination
Monitoring** — is installed for you the moment your workspace has its first
vault, and it uses whichever notification channels your other alerts use. You do
not need a Route, and you do not need to configure anything:
* **A destination this vault has never used.** A detokenize to a bucket with no
prior window in the last 30 days. A card number arriving at a processor you
have never sent to is the first signal of a stolen credential, and it fires on
the very first one — you do not have to wait for a pattern to form.
* **A spike over that destination's own normal.** The current window's count
measured against the baseline KnoxCall learned for that vault-and-destination
pair, using the same statistics the rest of the anomaly detection uses. A
quieter-than-usual window is not an incident and never fires.
**The alerts are about Live traffic only.** Test-mode detokenizes are counted in
their own separate history — so a test run can never teach the detector that an
unfamiliar host is familiar, and a Live destination you have used for months is
still a first for Test — but they never raise an alert. Pointing your sandbox at
a new endpoint is development, not an incident, and KnoxCall does not page you
for it.
Each window raises each alert at most once, so a burst of detokenizes to one new
destination is one notification rather than one per request.
### KnoxCall tells you before a stored card expires
A card on file stops working the day it expires, and **nothing on KnoxCall's side
errors when it does** — we hold a token, not an issuing relationship with the
card's bank. The first anyone hears about it is a decline at your payment
processor, usually on a renewal, with no mention of an expiry date anywhere in
the message.
So a card token can carry the **card's own expiry**, and KnoxCall raises an event
**60 days and again 30 days** before it, once each:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/vaults/cards/tokens \
-H "Authorization: Bearer $KC_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"4111111111111111","card_exp_month":7,"card_exp_year":2029}'
# → data: { "id": "…", "token": "4111114725124116", "expires_at": null,
# "created_at": "…", "card_expires_on": "2029-07-31" }
```
`card_exp_month` (1–12) and `card_exp_year` (**four digits** — `2029`, never
`29`) are optional, go together, and are accepted only by a `pan` vault. Half an
expiry, a two-digit year, or either field sent to a `generic` / `ssn` / `email`
vault is refused with `400 validation_error` rather than quietly dropped —
a quiet drop means no notice ever fires and you never find out.
`card_expires_on` comes back as the **last day of the expiry month**, because a
card is valid through the end of its month. You will also see it on every row of
`GET /v1/vaults/{vault}/tokens`.
**This is not the token's TTL.** `ttl_seconds` and `expires_at` are how long
*KnoxCall's token* lives; `card_exp_*` and `card_expires_on` are when *the card*
stops working. They are unrelated — a token with no TTL at all can belong to a
card that dies in fourteen months.
**Subscribe a webhook to `vault.token.expiring`** to receive the notice:
```json theme={"dark"}
{
"event": "vault.token.expiring",
"timestamp": "2026-09-17T08:30:04.117Z",
"data": {
"token_id": "0f4a…", "token": "4111114725124116",
"vault_id": "22c1…", "vault_name": "cards", "last4": "4116",
"card_expires_on": "2029-07-31", "days_until_expiry": 60,
"threshold_days": 60, "sandbox": false
}
}
```
`token` is the token you already store — for a `pan` vault it preserves the real
BIN and last four, which is what makes "your card ending **4116** expires next
month" possible without anyone holding the number. **The payload never carries
the card number**, and that is enforced rather than promised: a delivery whose
contents look card-shaped is refused server-side instead of sent.
Every notice is also written to your audit log as `vault.token.expiring` against
the token's id, so a workspace already piping `audit.event` into a SIEM receives
it without subscribing to anything new. A Test-mode card only ever reaches a
Test-mode webhook.
The notice is decided by a job that runs once a day, and it uses a **window**
rather than an exact day: if a run is missed, the notice catches up on the next
one instead of being skipped. A "60 days" event can therefore arrive a few days
late. It never arrives twice.
**Tokens created before 17 September 2026 carry `card_expires_on: null`, and
there is no backfill.** The expiry was never captured, so it is not in the
stored ciphertext and KnoxCall cannot recover it — those tokens get no notice
until the card is tokenized again. If you hold cards on file and want the
notices, re-tokenize on the next successful charge or the next time the customer
updates their details. The same applies to any token created without the two
fields, and to the `tokenize` **field action on a Route**, which sees one field
value and has no expiry to carry.
### Test cards
**Test mode has card numbers that fail on purpose**, so you can exercise your
decline path, your 3-D Secure path and your expiry path before the first real
card. They are ordinary card numbers in a `pan` vault — you tokenize them the
way you tokenize anything — and KnoxCall recognises them **only in the Test data
space**.
| Number | What happens on detokenize-and-send |
| - | - |
| `4242 4242 4242 4242` | Forwarded to your destination exactly like a real card. |
| `4000 0000 0000 0002` | **Not forwarded.** `402 sandbox_test_card_declined`. |
| `4000 0000 0000 3220` | **Not forwarded.** `402 sandbox_test_card_requires_action` — the shape a card that needs 3-D Secure takes before authentication. |
| `4000 0000 0000 0069` | Forwarded normally, but **tokenizes with `card_expires_on: "2020-01-31"`** when you supply no `card_exp_*` of your own. |
The two refusals answer the ordinary `/v1` error envelope on `POST /v1/proxy`
(and on `/wg` and your wrap subdomain), and the ordinary Route envelope on a
configured Route:
```json theme={"dark"}
{
"error": {
"type": "sandbox_test_card_declined",
"message": "Not forwarded. This request released the KnoxCall sandbox test card ••••••••••••0002, which the Test data space simulates as a DECLINE. Nothing was sent to api.stripe.com and this is KnoxCall's own simulated outcome, not a response from it or from any payment provider. In the Live data space this number is an ordinary card and is forwarded normally.",
"request_id": "3f1c8e2a-…"
}
}
```
The response also carries `X-Knox-Error: sandbox_test_card_declined` and **no**
`X-Knox-Destination-Status`, which is how a wrapped provider SDK tells a
KnoxCall answer from its provider's — so `stripe-node` sees a KnoxCall error,
never a Stripe decline.
**This is a simulation, and it is KnoxCall's shape — not any processor's.**
KnoxCall is a vault and a proxy; it does not authorize, capture, or hold an
issuing relationship, so it cannot tell you what *your* processor would have
said. What it can do is refuse to forward and say exactly why. Nothing is sent
upstream on a decline or a 3-D Secure outcome: **no request reaches your
destination at all.**
**402 is also `plan_limit`.** Branch on `error.type` (or `X-Knox-Error`), never
on the status alone.
Four things worth knowing:
* **The Live data space treats all four as ordinary card numbers.** There is no
Live behaviour here to switch off — the check returns before it reads anything
when the data space is Live.
* **Only a value a vault released is classified.** Pasting `4000 0000 0000 0002`
into a request body as clear text is refused by the raw-card-number egress
control (`403 raw_pan_egress_refused`), exactly as any other card number in a
clear body is. Test cards are not a way around that.
* **The card still leaves the vault.** A simulated decline writes the same single
`vault.detokenize` audit row, counts against the same detokenize meter, and
appears in the vault's **Destinations** view attributed to the destination you
named — because the number really was decrypted. Only the forward is withheld.
* **`4000 0000 0000 0069` is not simulated at all.** Its `card_expires_on` is a
real date in a real column, so the tokenize response, the token list and the
expiry job all read it the way they read a real card's. Supply your own
`card_exp_month` / `card_exp_year` and yours wins — KnoxCall never rewrites an
expiry you spelled out.
**An already-expired card gets no `vault.token.expiring` notice**, and that is
not specific to the test card. The 60- and 30-day windows look *forward*, so a
card whose expiry has passed is in neither. The test card proves the expiry is
captured, stored and readable; it cannot be used to make a notice fire on
demand.
### BIN intelligence: two fields that ship empty
A card token also carries two fields describing the **issuing bank**, derived
from the card's first six digits:
| Field | Shape |
| - | - |
| `card_funding_type` | `"credit"` / `"debit"` / `"prepaid"`, or `null` |
| `card_issuing_country` | ISO 3166-1 alpha-2, upper case (`"IE"`), or `null` |
They appear on the tokenize response, the bulk tokenize response and every row of
`GET /v1/vaults/{vault}/tokens`, beside `card_expires_on`. There is nothing to
send: both are derived at tokenize time, never supplied.
**Both are `null` on every token today, and we would rather say so than let you
discover it.** Naming a card's funding type and issuing country from its first
six digits requires a commercially licensed BIN table. KnoxCall has not
licensed one, and whether we do is an open business decision. Everything around
that table is built and shipped — the storage, the lookup, the API shape, the
SDK models — so on the day we license one the fields populate with no change to
your integration. Until then, expect `null`.
**Treat both as optional indefinitely.** Even with a table in place they are
`null` for every non-`pan` vault and for any card whose issuer the table does not
carry. Code that *requires* either field to be present will break on cards that
are perfectly valid.
**`brand` and the last four digits are a different thing and have always
worked.** Those come from the *published* issuer ranges, which need no licence —
and for a `pan` vault the token string itself preserves the real BIN and last
four, which is how "your card ending 4116" works without anyone holding the
number.
Only the first **six** digits are ever used for this lookup, and that is a
boundary in the code rather than a habit: the truncation and the lookup live in
one module, and the lookup refuses anything that is not exactly six digits.
### Why a Route field action refused your request
A Route can carry **field actions** — `tokenize`, `detokenize`, `encrypt`,
`decrypt` applied to selected JSON fields on the way out or on the way back.
When one of them cannot run, KnoxCall **refuses the request**: the upstream is
not called with the untransformed value, and the upstream's untransformed reply
is not delivered to you. That part never varies — the whole point of the action
is that the raw value does not travel.
What the answer tells you is *why*, and whether retrying can possibly help:
| Code | Request direction | Response direction | What happened | What to do |
| - | - | - | - | - |
| `relay_token_expired` | `422` | `502` | A token in the body has passed its TTL. | Tokenize the value again; the old token is gone for good. |
| `relay_destination_refused` | `403` | `502` | The route's upstream host is not on the vault's `allowed_destinations` — or the action is on the **response** direction, which is a plain read a `pan` vault never answers. | Add the route's upstream host to the vault's `allowed_destinations` (a `*.suffix` entry works), or move the action to the request direction. |
| `relay_plan_limit` | `402` | `502` | Your plan's monthly vault-operation allowance is used up. | Upgrade, or wait for the meter to roll over. |
| `relay_vault_disabled` | `409` | `502` | The vault named by the action has its **enabled** switch off. | Re-enable the vault in the dashboard. |
| `relay_value_rejected` | `422` | `502` | The value at a selected field is not one the vault's format accepts — a non-Luhn PAN at a `pan` vault, a malformed SSN or email, or a **card number at a vault that is not `pan`**. | Fix the value — or, for a card number, point the action at a `pan` vault. Never at a `generic` one: that is the case being refused. |
| `relay_token_space_exhausted` | `409` | `502` | The format-preserving token space for that specific input is full. | Stop retrying *that* input — a distinct token cannot be issued for it. |
| `relay_unavailable` | `503` | — | KnoxCall could not run the action: a key, a KMS or the database. | Retry. This one, and only this one, is safe to retry. |
| `relay_response_undeliverable` | — | `502` | The same, on the reply. | **Do not blindly retry** — the upstream call already happened. |
| `relay_response_not_retokenizable` | — | `502` | The request detokenized a **card number**, and the upstream's reply could not be scanned for it — compressed with an encoding KnoxCall does not decode, binary, or past the response-log ceiling. The reply was withheld rather than delivered unscanned. | Have the upstream answer uncompressed JSON/text, or move the `pan` detokenize to a route whose upstream does. **Do not blindly retry.** |
Three rules hold across the whole table:
* **A `4xx` is final.** Replaying the identical request is refused identically.
`503` is the only code that means "try again".
* **The response direction is always `502`**, whatever the reason, because the
upstream request *was performed*. A blind retry of a `POST` that already
charged a card is its own incident.
* **Every refusal is in your audit log**, as `route.action.refused` with the
reason and the `request_id` from the response body. Genuine KnoxCall faults
are `route.action.failed` instead, so the log answers "was this us or them?"
without guesswork.
## Encryption + rotation
Each vault has its own encryption key (managed via [Crypto Keys](/essentials/crypto-keys/crypto-keys-overview)). Tokens carry the key version they were encrypted under. When you rotate:
* A new key version becomes active.
* New tokens encrypt under the new version.
* Existing tokens still decrypt under their original version (no migration needed).
Destroying a vault destroys every key version. Tokens become permanently unreadable. This is **cryptographic erasure** — the data is unrecoverable even if the encrypted ciphertext is leaked from a backup.
**Deleting a single token is an erasure too, not just a row delete.** Each token's value is sealed under that token's *own* data key, and the vault key wraps only the data key. Deleting the token destroys the data key and removes the row in one transaction — so a copy of the row taken from a backup, a replica or a WAL segment *before* the delete can no longer be opened, even by someone who also holds the vault key. That is what makes a per-customer erasure request answerable without destroying the whole vault.
## Bulk and TTL
* **Bulk tokenize**: up to 1000 values per call via `POST /v1/vaults/{name}/tokens/bulk`. The batch runs in **one transaction** — all of it is stored or none of it is, so a failure partway through leaves nothing behind to reconcile.
* **TTL**: per-vault default + per-call override. Expired tokens 404 on detokenize and get pruned by an hourly cron. This is the **token's** lifetime and has nothing to do with `card_expires_on`, the **card's** expiry date — see "KnoxCall tells you before a stored card expires" above.
* **Metadata**: attach a small JSON object alongside each token (read back on detokenize). Encrypted at rest.
## See where your data went
Every vault's detail page has a **Destinations** tab — "Where this data went". It answers, for
the last 7, 30 or 90 days:
* **Which hosts** a value was released to, one row per host.
* **How many** releases went to each, and **how many per day**, so a sudden spike to a host you
do not recognise is visible rather than buried.
* **First seen** and **last seen** per host.
* A link straight into the **audit trail** for that vault, and a **Download as CSV** of the table.
It is derived from the `vault.detokenize` audit rows themselves — there is no second ledger that
could disagree with the log an auditor reads. Nothing on that screen contains a token, a value or
the last four digits of anything; the rows name hosts and counts.
For a `pan` vault the list is bounded by `allowed_destinations`: a host that is not on the list
could not have received a card number, which is why the page says so above the table.
One egress path is not in this table yet: a **Route** with a request-direction `detokenize`
field action releases a value without writing a per-token audit row, so it does not appear.
The page states this beneath the table rather than letting an empty table read as "nothing
left". `POST /v1/proxy` and the plain reads through `/v1` and the dashboard are all counted.
## Plan limits
| Tier | Vaults | Tokens | Format-preserving | Tokenize ops/mo | Detokenize ops/mo |
| - | - | - | - | - | - |
| Free | 1 | 1k | generic only | 1k | 1k |
| Starter | 5 | 50k | generic only | 10k | 10k |
| Pro | 25 | 1M | all (`pan`, `ssn`, `email`) | 1M | 1M |
| Enterprise | unlimited | unlimited | all | unlimited | unlimited |
## Who can list tokens and detokenize
Listing a vault's tokens (their strings keep a card's BIN and last four) and detokenizing a value are
both reveals. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission. A token issued to a person never carries `vault:detokenize`; listing a vault's tokens on `/v1` needs `vault:read` and does not take this check.
The dashboard no longer lists a vault's tokens when you open it: click **Show tokens** on the
**Tokens** tab. A token you delete is removed from the list on screen without listing again.
## Next steps
* [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — the encryption layer Vaults builds on
* [API reference →](/api-reference/overview)
# Webhook Signing overview
Source: https://docs.knoxcall.com/essentials/webhook-signing/overview
Sign outbound webhook events in any of six HMAC formats — Stripe, GitHub, Slack, AWS-SNS-HMAC, KnoxCall legacy, or a custom header. The recipient verifies the signature and rejects forgeries.
# Webhook Signing overview
KnoxCall's **Webhook Signing** is HMAC-as-a-service for outbound events. When KnoxCall fires a webhook (workflow output, alert dispatch, custom integration), the payload is signed with HMAC-SHA256 in the format the recipient expects. They verify the signature and trust the bytes; nobody can forge an event without the shared secret.
This is the inverse of [Inbound Webhooks](/essentials/inbound-webhooks/overview) — same wire formats, opposite direction.
## Why use it
| Problem | Without signing | With KnoxCall signing |
| - | - | - |
| Receiver gets a webhook with a sensitive payload | Anyone who learns the URL can forge events | Without the secret you can't forge a signature; receiver rejects |
| Each integration uses a different signing scheme | One-off code per recipient | Pick a format from the dropdown; KnoxCall emits headers the recipient already validates |
| Rotating the signing secret | Update KnoxCall + the recipient atomically (race) | Stripe-format accepts multiple `v1=` simultaneously — drop in the new secret, rotate the upstream, retire the old one |
| Replay attacks | Receiver re-runs the same event twice | Formats with timestamps (Stripe / Slack) include `t=` so the receiver enforces a 5-minute window |
## Supported formats
Same six formats as [Inbound Webhooks verification](/essentials/inbound-webhooks/format-guide):
| Format | Header | Replay-safe? | Use case |
| - | - | - | - |
| **stripe** | `Stripe-Signature: t=,v1=` | yes | Stripe-compatible receivers, anything that adopted the format |
| **github** | `X-Hub-Signature-256: sha256=` | no | GitHub, Postmark, SendGrid-shape receivers |
| **slack** | `X-Slack-Signature: v0=` + timestamp | yes | Slack-compatible event handlers |
| **aws-sns** | `x-amz-sns-signature: ` | no | KnoxCall-shaped SNS receivers (not real AWS RSA-SNS) |
| **legacy** | `X-Webhook-Signature: sha256=` | no | KnoxCall-to-KnoxCall flows; older subscribers |
| **custom** | caller-named, `sha256=` | no | Internal tools that don't match a named scheme |
The **killer test** that proves the formats are right: KnoxCall's `stripe`-format output is verified end-to-end by Stripe's own SDK in the test suite. If `stripe.webhooks.constructEvent` accepts our header, the wire format is byte-correct. The same kind of round-trip test exists for every format, and KnoxCall's own [Inbound Webhooks](/essentials/inbound-webhooks/overview) verifier is the inverse — outbound + inbound share the same six format definitions, so the round-trip is structurally guaranteed.
## How recipients verify
Every format follows the same pattern: HMAC-SHA256 over a defined byte sequence (often the raw body, sometimes prefixed with a timestamp), encoded as hex or base64, dropped into a known header. The recipient recomputes the HMAC with the shared secret and compares constant-time.
For the wire-level details (what bytes get signed, what the header looks like, replay-window semantics), see the [Inbound Webhooks format guide](/essentials/inbound-webhooks/format-guide) — both directions speak the same protocol.
## Where signing happens in KnoxCall
Webhook Signing applies anywhere KnoxCall fires HTTP events outward:
* **Workflows** — emit signed events to integrations.
* **Alerts** — sign Slack-format webhook deliveries to your incident channel.
* **Custom dispatcher** — pick a format on the subscription; KnoxCall handles the signing.
The format is per-subscription, not global, so different recipients can each get the format they expect.
## Choosing a format
* **Talking to a real provider that documents a scheme?** Use the matching format. Stripe receivers expect `stripe`, Slack receivers expect `slack`, GitHub-shaped receivers expect `github`, etc.
* **Talking to your own service?** Use `legacy` (KnoxCall's default) for KnoxCall-to-KnoxCall flows, or `custom` if you want to pick the header name yourself.
* **Talking to an integration that doesn't document a scheme?** `legacy` or `custom` — most receivers will accept "HMAC-SHA256 of the body in this header" if you tell them which header to read.
## Plan limits
Multi-format webhook signing (everything except `legacy`) is gated to paid tiers — Pro and above. Free / Starter ship with `legacy` only. See [pricing](https://knoxcall.com/pricing) for current limits.
## Next steps
* [Inbound Webhooks overview →](/essentials/inbound-webhooks/overview) — verify HMAC-signed webhooks coming *in* from external providers
* [Format guide →](/essentials/inbound-webhooks/format-guide) — wire-level details (shared with inbound)
# Set Up KnoxCall with Your AI Agent
Source: https://docs.knoxcall.com/getting-started/ai-agent-onboarding
A step-by-step runbook your coding agent (Claude Code, Cursor, etc.) can follow to create a KnoxCall account and integrate your codebase — end to end, no browser required.
This page is written to be executed by an AI coding agent. If you're a human: paste the prompt below into your agent and it can do everything else. If you're an agent: follow the runbook top to bottom.
## For humans — the one-liner
Paste this into Claude Code (or any coding agent with shell access) from inside your project:
```text theme={"dark"}
Set up KnoxCall for this project. Follow the runbook at
https://docs.knoxcall.com/getting-started/ai-agent-onboarding — create an
account with my email , proxy this project's outbound API
calls through KnoxCall in Test mode, and show me the verification steps.
```
Your agent will start the account, then wait for you to do one thing before it can go any further: **click the sign-in link KnoxCall emails you**. That click is what creates the workspace and releases the agent's test credential. After that it builds the whole integration against the isolated Test sandbox on its own, and comes back to you once more to approve the switch to Live mode.
## For agents — the runbook
`POST https://api.knoxcall.com/v1/signup` is unauthenticated. It does **not** return a credential: it returns a claim handle and emails a sign-in link to the owner.
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/signup \
-H "Content-Type: application/json" \
-d '{
"email": "owner@company.com",
"tenant_name": "Company Name",
"country": "US"
}'
```
The reply is always `202`:
```json theme={"dark"}
{
"data": {
"status": "pending",
"claim_handle": "sck_…",
"claim_path": "/v1/signup/claim",
"poll_after_seconds": 5,
"expires_at": "2026-08-29T09:14:22.117Z"
}
}
```
Notes:
* Store `data.claim_handle`. It is a **secret** — it is what collects the API key — and it is valid for 24 hours.
* Omit `tenant_slug` — the server derives an available one. Don't iterate on slug collisions yourself.
* Rate limit: 3 signups per hour per IP. Do not retry a 4xx.
* The reply is identical whether or not the address already has an account. Deliberately: it means this endpoint cannot be used to find out who has a KnoxCall account. It also means a `202` is not proof the workspace will be created — only the click is.
Stop and tell the user, in these words: *"I've started your KnoxCall signup. Click the sign-in link in the email from KnoxCall — that creates your workspace and releases my test credential. I'll wait."*
Then poll, no faster than `poll_after_seconds`:
```bash theme={"dark"}
curl -X POST https://api.knoxcall.com/v1/signup/claim \
-H "Content-Type: application/json" \
-d '{"claim_handle": "sck_…"}'
```
* `202` with `"status": "pending"` — the link has not been clicked yet. Keep waiting; this is a normal success, not an error.
* `200` with `"status": "ready"` — the workspace exists. `data.starter.api_key.api_key` is a **one-time plaintext test key**. It is never shown again, and a second poll returns `409`.
* `404` — the handle is unknown or expired. Start again at the previous step.
From the `200` response, store these three values now — the key in the user's secret manager or an untracked `.env.local`, never in a tracked file:
```bash theme={"dark"}
KNOXCALL_TEST_KEY='tk_…' # data.starter.api_key.api_key (the one-time key)
KNOXCALL_KEY_ID='tk_…' # data.starter.api_key.key_id (its OAuth client_id)
KNOXCALL_TENANT_SLUG='…' # data.tenant.slug
```
The account is on the free plan and the tenant is in Test mode. Everything below works now.
The claim response includes `data.starter.curl`: two commands that exchange the starter key for a one-hour access token and call a seeded demo route (`getting-started`, proxying httpbin.org) with it. Run it verbatim — it needs only `curl` and `sed`, not `jq`. A JSON echo from httpbin proves the proxy, your credential, and the tenant are all live.
Scan the project for outbound API calls and their credentials: HTTP client calls (fetch/axios/httpx/requests/Guzzle), base URLs in config, and API keys in `.env` files. Each distinct upstream API becomes one KnoxCall **route**; each credential becomes one **secret**.
The test key itself does **not** authenticate API calls. Every new workspace is in OAuth-only mode, so `Authorization: Bearer tk_…` answers `401 use_oauth` on the Management API, and the proxy refuses it too. The key is an OAuth client instead: its `client_id` is `KNOXCALL_KEY_ID`, and its `client_secret` is the rest of the key after `_`. Exchange it for a one-hour access token at the sandbox token endpoint — the same exchange `data.starter.curl` does:
```bash theme={"dark"}
KNOXCALL_CLIENT_SECRET="${KNOXCALL_TEST_KEY#${KNOXCALL_KEY_ID}_}"
TOKEN=$(curl -s -X POST https://sandbox.knoxcall.com/oauth/token \
-u "$KNOXCALL_KEY_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
| sed -nE 's/.*"access_token" *: *"([^"]+)".*/\1/p')
```
The token (`kc_test_…`) lasts `expires_in` seconds (one hour) and comes with no refresh token. When a call answers `401` with `invalid_token`, or the hour is up, run the exchange again. Send the `tk_` key to the token endpoint and nowhere else.
With the token, `https://sandbox.knoxcall.com/v1` is the Test-mode Management API. Full reference: [API Reference](/api-reference/overview), OpenAPI spec at `/api-reference/openapi.yaml`. For each upstream API:
```bash theme={"dark"}
# 1. A secret for the upstream's credential
curl -s -X POST https://sandbox.knoxcall.com/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "stripe-api-key", "value": "sk_test_..."}'
# 2. A route to the upstream that injects the secret
ROUTE_ID=$(curl -s -X POST https://sandbox.knoxcall.com/v1/routes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "stripe",
"target_base_url": "https://api.stripe.com",
"inject_headers_json": {"Authorization": "Bearer {{secret:stripe-api-key}}"}
}' \
| sed -nE 's/.*"id" *: *"([^"]+)".*/\1/p')
# 3. Test mode only: accept calls without a client allowlist, like the seeded
# getting-started route
curl -s -X PATCH https://sandbox.knoxcall.com/v1/routes/$ROUTE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"requires_clients": false}'
```
* The injection field is `inject_headers_json`. `POST /v1/routes` ignores fields it does not know rather than rejecting them, so a misspelled field such as `inject_headers` creates a route that injects nothing, and the upstream answers with its own `401`.
* A new route starts with `requires_clients: true`: only requests from authorized clients (by IP or mTLS certificate) are accepted, and until one is attached every call answers `403` "No authorized clients are configured". Attaching a client to a route is done in the dashboard, not through `/v1`, which is why step 3 turns the allowlist off for the Test route instead. The owner decides the Live setting (last step).
* Prefer `{{secret_id:UUID}}` over `{{secret:NAME}}` in anything you persist — the UUID is `data.id` in the secret's create response. Names break on rename; UUIDs don't.
* The free plan caps how many routes and secrets each mode can hold (the seeded starter route does not count). A create past the cap answers `402 plan_limit`: stop and tell the user rather than retrying.
Exactly three changes per integration — see the [AI Integration Guide](/advanced/ai-integration-guide) for worked before/after examples and the path-concatenation rules:
1. Replace the API base URL with `https://sandbox-{tenant_slug}.knoxcall.com/api` (Test) — the path after the upstream's base URL stays as-is.
2. Send `Authorization: Bearer ` and `x-knoxcall-route: `. The application mints its own token with the same client-credentials exchange (client id and secret read from its environment, re-minted before the hour runs out), or uses an SDK, which does that for it. KnoxCall removes its own `Authorization` header before forwarding and injects the route's.
3. Remove the old credential from the code and env files — KnoxCall injects it server-side now.
Check one rewritten call from the shell:
```bash theme={"dark"}
curl -s https://sandbox-$KNOXCALL_TENANT_SLUG.knoxcall.com/api/v1/customers \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: stripe"
```
Routes are identified by the `x-knoxcall-route` header, **not** by path matching. Paths concatenate: `target_base_url.pathname + request.path`. Do not duplicate the base path.
`x-knoxcall-key: ` is the legacy way to authenticate a proxy call. It works only in a workspace whose owner has turned OAuth-only mode off, and every new workspace refuses it. Do not write it into new code.
Run the project's test suite or exercise the changed call sites. Proxied requests appear in the request log, `GET https://sandbox.knoxcall.com/v1/logs` with the same Bearer token; configuration changes are in `GET https://sandbox.knoxcall.com/v1/audit-logs`.
Then tell the user, explicitly:
1. **Log in at [https://knoxcall.com](https://knoxcall.com)** (the sign-in link they already clicked verified the address and opened the dashboard).
2. Switch to Live mode, recreate the secrets with production credentials, recreate the routes, and mint a Live API key from API Keys. A Live key is an OAuth client in exactly the same way: the application exchanges it at `POST https://api.knoxcall.com/oauth/token`, and a Live token calls `https://api.knoxcall.com/v1` and the Live proxy host.
3. On each Live route, attach the application's servers as authorized clients, or decide deliberately to leave the client allowlist off.
4. Swap `sandbox-{slug}` → `{slug}` in the proxy host, the token endpoint host to `api.knoxcall.com`, and the test client id and secret for the Live ones — via the environment config, not a code change.
Do not attempt to mint Live keys or move production credentials yourself unless the user explicitly asks.
### The same flow with the Node SDK
`npm install @knoxcall/sdk`. The client takes the key as an OAuth client id and secret, and mints and re-mints the access token itself:
```ts theme={"dark"}
import { signup, claimSignup, KnoxCall } from "@knoxcall/sdk";
const started = await signup({ email: "owner@company.com", tenant_name: "Company Name" });
// …the owner clicks the sign-in link KnoxCall emailed…
let claim = await claimSignup({ claim_handle: started.claim_handle });
while (claim.status === "pending") {
await new Promise((r) => setTimeout(r, started.poll_after_seconds * 1000));
claim = await claimSignup({ claim_handle: started.claim_handle });
}
// claim.starter.api_key.api_key is shown exactly once — store it now.
const { key_id, api_key } = claim.starter.api_key;
const knox = new KnoxCall({
clientId: key_id,
clientSecret: api_key.slice(key_id.length + 1),
sandbox: true,
});
await knox.secrets.create({ name: "stripe-api-key", value: process.env.STRIPE_SECRET_KEY! });
const route = await knox.routes.create({ name: "stripe", target_base_url: "https://api.stripe.com" });
await knox.routes.update(route.id, {
inject_headers_json: { Authorization: "Bearer {{secret:stripe-api-key}}" },
requires_clients: false, // Test mode only; see the build step above
});
const res = await knox.call("stripe", { path: "/v1/customers" });
```
## Contract summary for tools
| What | Value |
| - | - |
| Signup | `POST https://api.knoxcall.com/v1/signup` — unauthenticated, JSON `{email, tenant_name}` minimum. Always `202` + `claim_handle`; no credential |
| Claim | `POST https://api.knoxcall.com/v1/signup/claim` — unauthenticated, JSON `{claim_handle}`. `202` until the emailed link is clicked, then `200` once with the test key; `404` unknown or expired, `409` already collected |
| Credential | The test key is an OAuth client: `client_id` = `data.starter.api_key.key_id`, `client_secret` = the rest of `data.starter.api_key.api_key` after `_`. The raw key is accepted only by the token endpoint. Anywhere else it is refused with `401`: `use_oauth` on `/v1`; on the proxy, `This tenant requires OAuth 2.1 tokens` when sent as `x-knoxcall-key`, or `Unauthorized` when sent as `Authorization: Bearer tk_…` |
| Test token | `POST https://sandbox.knoxcall.com/oauth/token` — HTTP Basic `client_id:client_secret`, form `grant_type=client_credentials`. Returns a one-hour `kc_test_…` `access_token` and no refresh token; re-mint on `401 invalid_token` or expiry |
| Test Management API | `https://sandbox.knoxcall.com/v1` — `Authorization: Bearer ` |
| Live | `POST https://api.knoxcall.com/oauth/token` with a Live key the owner minted, then `https://api.knoxcall.com/v1` with the `kc_live_…` token |
| Test proxy host | `https://sandbox-{tenant_slug}.knoxcall.com/api/...` |
| Live proxy host | `https://{tenant_slug}.knoxcall.com/api/...` |
| Proxy headers | `Authorization: Bearer `, `x-knoxcall-route`, optional `x-knoxcall-environment`. `x-knoxcall-key` is legacy: refused unless the owner turned OAuth-only mode off |
| Route create | `POST /v1/routes` with `name`, `target_base_url`, `inject_headers_json` (unknown fields are ignored, not rejected). A new route has `requires_clients: true`; in Test, `PATCH /v1/routes/{id}` with `{"requires_clients": false}` |
| SDKs | Node, Python, Go, PHP, Ruby — all export `signup()` and the claim poll (`claimSignup` / `claim_signup` / `ClaimSignup` / `claimSignup`); install commands in [SDKs](/sdks/overview). The Node client takes `clientId` + `clientSecret` and does the token exchange itself |
| Machine-readable docs | [https://docs.knoxcall.com/llms.txt](https://docs.knoxcall.com/llms.txt) · OpenAPI: `/api-reference/openapi.yaml` |
# Core Concepts
Source: https://docs.knoxcall.com/getting-started/core-concepts
Understand the three foundational concepts of KnoxCall: Clients, Routes, and Secrets
# Core Concepts
Before diving into KnoxCall, it's essential to understand the three foundational concepts that power the platform:
1. **Clients** - Who can access your routes
2. **Routes** - Where requests go
3. **Secrets** - How credentials are managed securely
Think of these as the building blocks of your API routing infrastructure.
***
## 1. Clients = Authorization (Who Can Access)
**Clients** are authorized IP addresses or API keys that can call your routes.
### What is a Client?
A client represents a **trusted source** that's allowed to make requests through KnoxCall. This can be:
* Your production server's IP address
* Your office network IP range
* Your development machine
* A partner's API server
* A mobile app backend
### Why Use Clients?
Clients provide **network-level security**:
✅ Only whitelisted IPs can access your routes
✅ Block unauthorized access automatically
✅ Track which systems are making requests
✅ Different clients for dev, staging, and production
### Simple Example
```text theme={"dark"}
Your Server (52.123.45.67) → Allowed ✅
Random Internet User → Blocked ❌
```
### Key Concept
**IP Whitelisting**: KnoxCall checks every incoming request's IP address. If it's not on your authorized client list, the request is rejected before it even reaches your route.
***
## 2. Routes = Forwarding Rules (Where Requests Go)
**Routes** define how incoming requests are forwarded to your backend APIs.
### What is a Route?
A route is a **smart proxy** that sits between your clients and your backend services. It:
* Receives requests from authorized clients
* Applies security rules and transformations
* Forwards to your backend API
* Returns the response
### The Flow
```text theme={"dark"}
Client Request
↓
KnoxCall Route (validates, transforms, logs)
↓
Your Backend API
↓
Response back to client
```
### Why Use Routes?
Routes solve common API problems:
✅ **Hide backend URLs** - Clients never know your real endpoints
✅ **Centralize credentials** - API keys injected server-side
✅ **Monitor everything** - All requests logged automatically
✅ **Add security layers** - Rate limiting, IP checks, signatures
✅ **Manage environments** - Dev, staging, prod with one route
### Simple Example
**Route name**: `user-api`
**Target**: `https://api.example.com`
**Methods**: GET, POST
When client calls:
```text theme={"dark"}
https://YOUR-SUBDOMAIN.knoxcall.com/users/123
```
KnoxCall forwards to:
```text theme={"dark"}
https://api.example.com/users/123
```
***
## 3. Secrets = Encrypted Credentials (How Secrets Are Managed)
**Secrets** are encrypted credentials (API keys, passwords, tokens) that KnoxCall injects into your requests **server-side**.
### What is a Secret?
A secret is a **secure vault entry** containing sensitive data that:
* Is encrypted with AES-256-GCM
* Never exposed to clients
* Injected into requests automatically
* Can be rotated without code changes
### Why Use Secrets?
Secrets prevent **credential exposure**:
✅ **Never in client code** - API keys stay on KnoxCall's servers
✅ **Encrypted storage** - Military-grade encryption
✅ **Easy rotation** - Update once, affects all routes
✅ **Audit trail** - Track when secrets are used
### The Problem Without Secrets
```javascript theme={"dark"}
// ❌ BAD: API key exposed in frontend
fetch("https://api.stripe.com/charges", {
headers: { "Authorization": "Bearer sk_live_abc123..." }
});
```
Anyone with browser dev tools can see your Stripe key!
### The Solution With Secrets
```javascript theme={"dark"}
// ✅ GOOD: No secrets exposed
fetch("https://YOUR-SUBDOMAIN.knoxcall.com/api/payments", {
headers: { "x-knoxcall-route": "stripe-payments" }
});
```
KnoxCall injects your Stripe key **server-side**. The client never sees it!
### How It Works
1. Store secret in KnoxCall: `stripe_prod_key = sk_live_abc123...`
2. Reference in route config: `{{secret:stripe_prod_key}}`
3. KnoxCall decrypts and injects automatically
4. Backend receives real API key
5. Client never sees plaintext value
***
## How They Work Together
These three concepts work in harmony to secure and route your API traffic:
```text theme={"dark"}
┌─────────────────────────────────────────────────────────┐
│ 1. CLIENT │
│ (Authorized IP: 52.123.45.67) │
│ │
│ Makes request with x-knoxcall-route: user-api │
└─────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 2. KNOXCALL ROUTE │
│ (user-api) │
│ │
│ • Validates client IP ✓ │
│ • Loads route configuration ✓ │
│ • Decrypts secrets ✓ │
│ • Injects credentials into headers ✓ │
│ • Logs request ✓ │
│ • Forwards to backend ✓ │
└─────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 3. YOUR BACKEND API │
│ (https://api.example.com) │
│ │
│ Receives request with: │
│ • Real API credentials (from secrets) │
│ • All original request data │
│ • No knowledge of KnoxCall │
└─────────────────────────────────────────────────────────┘
```
***
## Real-World Example
Let's see how all three work together in a real scenario:
### Scenario: Mobile App Calling Stripe API
**Goal**: Your mobile app needs to charge customers via Stripe, but you can't embed the Stripe API key in the app (users could extract it).
**Solution with KnoxCall**:
### Step 1: Create a Client
```text theme={"dark"}
Name: production_app_server
IP: 52.123.45.67 (your app backend server)
Type: Server
```
### Step 2: Create a Secret
```text theme={"dark"}
Name: stripe_prod_key
Value: sk_live_EXAMPLE_abc123xyz789... (encrypted)
```
### Step 3: Create a Route
```text theme={"dark"}
Name: stripe-payments
Target: https://api.stripe.com
Header Injection:
Authorization: Bearer {{secret:stripe_prod_key}}
Requires Clients: ✓
```
### Step 4: Assign Client to Route
Connect "production\_app\_server" to "stripe-payments" route.
### Step 5: Make Request
Your app backend calls your proxy subdomain. The path after the
subdomain is forwarded **verbatim** to the route's target — here
`/v1/charges` is appended to `https://api.stripe.com`, so Stripe
receives `https://api.stripe.com/v1/charges`. (This `/v1` is Stripe's
path, not KnoxCall's own `/v1` platform API prefix.)
```bash theme={"dark"}
curl https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges \
-H "x-knoxcall-route: stripe-payments" \
-H "Authorization: Bearer $TOKEN" \
-d "amount=1000" \
-d "currency=usd"
```
`$TOKEN` is a short-lived KnoxCall OAuth token (see [Authentication](/api-reference/authentication)) — or skip token handling entirely with the SDK: `await client.call("stripe-payments", { method: "POST", path: "/v1/charges", body: { … } })`.
### What Happens
1. **Client Check**: KnoxCall sees request from 52.123.45.67 → Authorized ✅
2. **Route Lookup**: Loads "stripe-payments" route config
3. **Secret Injection**: Decrypts `stripe_prod_key` and adds to headers
4. **Forward**: Sends to Stripe with real API key
5. **Response**: Returns Stripe's response to your app
6. **Logging**: Records everything in audit logs
**Result**: Your Stripe API key is never exposed to your mobile app or clients!
***
## Quick Comparison
| Concept | Purpose | Example |
| - | - | - |
| **Client** | Who can access | `52.123.45.67` (your server's IP) |
| **Route** | Where to forward | `stripe-payments` → `https://api.stripe.com` |
| **Secret** | Credentials to inject | `stripe_prod_key` = `sk_live_abc123...` |
***
## Next Steps
Now that you understand the core concepts, you're ready to start building:
Follow the step-by-step tutorial
Set up your first API route
Authorize an IP address
Store credentials securely
See real-world route patterns
Let AI configure routes for you
***
## Common Questions
### Do I need all three?
**For basic usage**: You need at least a **Route**. Clients and Secrets are optional but highly recommended for security.
**For production**: You should use all three for maximum security and flexibility.
### Can I have multiple clients per route?
**Yes!** You can assign multiple clients to a single route. For example, both your production server and staging server can access the same route.
### Can I use multiple secrets in one route?
**Yes!** You can inject multiple secrets into headers and body. For example:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_key}}",
"X-API-Key": "{{secret:sendgrid_key}}"
}
```
### What if my IP changes frequently?
Both client types (**User** and **Server**) are IP-allowlist identities — each client is pinned to one or more IP addresses, so a changing IP would normally be rejected. For development machines where the IP changes regularly, use a **test API key**, which skips the client IP check entirely. (API keys are managed separately and are not tied to a client type.)
***
**Remember**: Clients = Who, Routes = Where, Secrets = How. Master these three, and you've mastered KnoxCall!
# Creating Your First Client
Source: https://docs.knoxcall.com/getting-started/first-client
Secure your routes with IP whitelisting. Learn how to create clients and assign them to routes.
# Creating Your First Client
Add IP-based access control to your routes by creating and assigning clients.
## What is a Client?
A **client** in KnoxCall represents an **authorized IP address** or IP range that can access your routes. This provides network-level security in addition to API key authentication.
**Important**: Clients are NOT the same as your API credentials!
* **Credentials** (OAuth tokens or API keys) = Authentication (who you are)
* **Clients** = Authorization (where you're calling from)
Both are checked for secure routes.
## Why Use Clients?
**Without clients** (client IP authorization disabled):
* Anyone with your API key can call your routes from anywhere
* Higher risk if API key is leaked
**With clients** (client IP authorization enabled):
* Only whitelisted IPs can call routes, even with a valid API key
* Defense-in-depth security model
* Useful for server-to-server integrations
## Client Types
### Server Clients
**Use case**: Backend servers with static IPs
**Examples**:
* Your production app server (`52.123.45.67`)
* Office network (`192.168.1.0/24`)
* Cloud function with fixed egress IP
**IP Format**: Single IP or CIDR range
```text theme={"dark"}
52.123.45.67
192.168.1.0/24
10.0.0.0/16
```
### User Clients
**Use case**: Development machines, mobile devices
**Examples**:
* Your laptop for testing
* Team member workstations
* VPN exit nodes
**IP Format**: Usually single IPs
```text theme={"dark"}
203.45.67.89
```
User IPs often change (mobile networks, home ISPs). For production, prefer server clients with static IPs.
## Create a Client
### Step 1: Find Your IP Address
First, determine what IP address to whitelist:
**Your current IP:**
```bash theme={"dark"}
curl https://api.ipify.org
```
**Your server's IP:**
```bash theme={"dark"}
curl https://api.ipify.org
# Run this command on your server
```
**IP range (CIDR):** Use a calculator like [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr)
### Step 2: Navigate to Clients
1. Open the **Proxy** section in the sidebar
2. Select **Clients**
3. Click **Add Client**
### Step 3: Fill in Client Details
**Client Name:**
```text theme={"dark"}
Production App Server
```
*Descriptive name for this IP/range*
**Type:** Select one
* **Server**: Backend servers, static IPs
* **User**: Development machines, laptops
**IP Address:**
```text theme={"dark"}
52.123.45.67
```
*Or CIDR range like `192.168.1.0/24`*
**Multiple IPs** (comma-separated):
```text theme={"dark"}
52.123.45.67,52.123.45.68,52.123.45.69
```
**Description** (optional):
```text theme={"dark"}
AWS EC2 production instance in us-east-1
```
**Enabled:** ✓ ON
### Step 4: Save
Click **Create Client**
Your client is created but NOT yet assigned to any routes.
## Assign Client to a Route
Clients must be assigned to specific routes and environments to grant access.
### Step 1: Open Your Route
1. Navigate to **Routes**
2. Click your route (e.g., "my-first-route")
### Step 2: Go to Clients Tab
Click the **Clients** tab
This shows which clients are allowed for each environment.
### Step 3: Assign Client
1. Find the environment (e.g., "production")
2. Click **Assign Clients**
3. Select your client from the list
4. Click **Save**
Now requests from that IP can access this route (in this environment)!
## Test Client Access
In the examples below, `$TOKEN` is a short-lived KnoxCall OAuth token (see [Authentication](/api-reference/authentication)).
### From Authorized IP
```bash theme={"dark"}
# Should work (200 OK)
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-first-route"
```
### From Unauthorized IP
```bash theme={"dark"}
# Should fail (403 Forbidden)
# Try from different machine/network
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-first-route"
# Response:
# {"error": "IP not authorized"}
```
## Client Connectivity Check
KnoxCall can ping your server clients to verify they're online.
### Run Connectivity Test
1. Go to **Clients** page
2. Find your server client
3. Click **Ping** button
**Online**: Green checkmark ✅
**Offline**: Red X ❌
This is useful for monitoring server health.
Ping requires ICMP to be allowed. If your server blocks ICMP, the ping will fail even if the server is online.
## Advanced: CIDR Ranges
CIDR notation allows you to whitelist entire subnets.
**Common ranges:**
```text theme={"dark"}
192.168.1.0/24 → 192.168.1.0 - 192.168.1.255 (256 IPs)
10.0.0.0/16 → 10.0.0.0 - 10.0.255.255 (65,536 IPs)
172.16.0.0/12 → 172.16.0.0 - 172.31.255.255 (1,048,576 IPs)
```
**Use cases:**
* Office network: `192.168.1.0/24`
* AWS VPC: `10.0.0.0/16`
* Cloud provider IP range: Specific to provider
**Calculator:** [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr)
## Disable Client IP Authorization
To allow requests from any IP (with valid API key only):
1. Edit your route
2. Find **Requires Clients** toggle
3. Turn it OFF
4. Save
Only disable for public routes or testing. Production routes should use client IP authorization.
## Multiple Clients per Route
You can assign multiple clients to a route:
**Example scenario:**
* `office-network` → `192.168.1.0/24`
* `aws-prod-server-1` → `52.123.45.67`
* `aws-prod-server-2` → `52.123.45.68`
All three can access the route.
## Environment-Specific Clients
Clients are assigned **per environment**. This lets you use different IPs for dev/staging/production:
**Development environment:**
* `dev-laptop` → `203.45.67.89`
**Staging environment:**
* `staging-server` → `34.56.78.90`
**Production environment:**
* `prod-server-1` → `52.123.45.67`
* `prod-server-2` → `52.123.45.68`
Same route, different allowed IPs per environment!
## Common Issues
### "IP not authorized" Error
**Causes:**
1. Your IP isn't whitelisted
2. Client not assigned to route environment
3. Client is disabled
4. Your IP changed (mobile/home networks)
5. Request is going through proxy/CDN with different IP
**Debug:**
1. Check actual IP: `curl https://api.ipify.org`
2. Compare with whitelisted IP in client settings
3. Verify client is assigned to route + environment
4. Check client is enabled
5. Look at request logs to see incoming IP
### Client Shows Offline
**Causes:**
* ICMP (ping) blocked by firewall
* Server is actually down
* Network routing issues
**Not a blocker**: Routes still work if connectivity check fails
### Multiple IPs Needed
**Solution**: Use CIDR ranges or comma-separated IPs
```text theme={"dark"}
52.123.45.67,52.123.45.68,52.123.45.69
```
Or:
```text theme={"dark"}
52.123.45.64/28 (includes .65 through .78)
```
### IP Changes Frequently
**For development:**
* Use test API keys (skip IP check)
* Or temporarily disable `requires_clients`
**For production:**
* Use static IPs (elastic IPs on AWS, etc.)
* Or use VPN with fixed egress IP
* Or use cloud provider IP ranges
## Security Best Practices
✅ **Do:**
* Use static IPs for production clients
* Combine with API key authentication
* Use CIDR ranges for office networks
* Enable connectivity monitoring
* Document client purpose in description
❌ **Don't:**
* Use `0.0.0.0/0` (allows all IPs - defeats the purpose)
* Add untrusted IPs to production routes
* Forget to remove old/unused clients
* Share clients across unrelated routes
## Next Steps
Now that you understand clients:
Assign different clients per environment
Add HMAC signatures for extra security
Add encrypted credentials to requests
Limit requests per client
## Related Concepts
* **API Credentials** (OAuth tokens or API keys): Authentication layer (who you are)
* **Clients**: Authorization layer (where you're calling from)
* **Environments**: Different client sets for dev/staging/prod
* **Request Signing**: Additional verification via HMAC signatures
***
* **Level**: beginner
* **Time**: 5 minutes
`clients`, `ip-whitelist`, `security`, `authorization`
# Creating Your First Environment
Source: https://docs.knoxcall.com/getting-started/first-environment
Set up dev, staging, and production environments. Route the same API to different backends based on environment.
# Creating Your First Environment
Set up development, staging, and production environments to safely test changes before deploying to production.
## What is an Environment?
An **environment** in KnoxCall allows you to use the **same route** with **different configurations** for dev, staging, and production.
**Same route, different configurations per environment:**
| Aspect | Development | Staging | Production |
| - | - | - | - |
| **Target URL** | `http://localhost:3000` | `https://staging-api.example.com` | `https://api.example.com` |
| **Secrets** | `dev_api_key` | `staging_api_key` | `prod_api_key` |
| **Allowed IPs** | Your laptop | Staging server | Production servers |
| **Rate Limits** | Unlimited | 1000/min | 100/min |
## Why Use Environments?
✅ **Test safely**: Try changes in dev/staging before production
✅ **Prevent mistakes**: Can't accidentally break production
✅ **Easy switching**: Change environment with one header
✅ **Different security**: Relaxed limits for dev, strict for prod
✅ **Separate credentials**: Dev keys for dev, prod keys for prod
## Understanding Environment Overrides
KnoxCall uses a **base + override** system:
1. **Base route**: Default configuration (usually production)
2. **Environment overrides**: Environment-specific changes
**Override pattern**: `NULL = inherit from base`
**Example:**
```text theme={"dark"}
Base Route (production):
target_url: https://api.production.com
inject_headers: {"X-API-Key": "{{secret:prod_key}}"}
rate_limit: 100/min
Development Override:
target_url: http://localhost:3000 (overridden)
inject_headers: NULL (inherited from base)
rate_limit: NULL (inherited from base)
Staging Override:
target_url: https://api.staging.com (overridden)
inject_headers: {"X-API-Key": "{{secret:staging_key}}"} (overridden)
rate_limit: 1000/min (overridden)
```
## Create Environments
By default, KnoxCall creates a **"production"** environment for you. Let's add development and staging.
### Step 1: Navigate to Environments
1. Open the **Proxy** section in the sidebar
2. Select **Environments**
3. You'll see "production" already exists
### Step 2: Create Development Environment
Click **Add Environment**
**Environment Name:**
```text theme={"dark"}
development
```
*Lowercase, no spaces. Used in API requests.*
**Display Name:**
```text theme={"dark"}
Development
```
*Friendly name shown in UI*
**Description** (optional):
```text theme={"dark"}
Local development servers and testing
```
**Color:** Choose a color
* 🟢 Green (`#00ff88`) for development
**Set as Default:** ❌ NO
*Leave production as default*
Click **Create**
### Step 3: Create Staging Environment
Click **Add Environment** again
**Environment Name:**
```text theme={"dark"}
staging
```
**Display Name:**
```text theme={"dark"}
Staging
```
**Description:**
```text theme={"dark"}
Pre-production testing environment
```
**Color:**
* 🟡 Yellow (`#ffaa00`) for staging
**Set as Default:** ❌ NO
Click **Create**
Now you have three environments: development, staging, production!
## Configure Route for Environments
Now configure your route to use different URLs per environment.
### Step 1: Edit Your Route
1. Navigate to **Routes**
2. Click your route (e.g., "my-first-route")
3. Click **Edit**
### Step 2: Set Base Configuration (Production)
This is your **base environment** (production):
**Target Base URL:**
```text theme={"dark"}
https://api.production.com
```
**Inject Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:prod_api_key}}"
}
```
**Base Environment:**
```text theme={"dark"}
production
```
Save the base configuration.
### Step 3: Add Environment Overrides
Now add overrides for development and staging.
#### Development Override
1. Click **Environment** tab
2. Select **development** environment
3. Configure:
**Target Base URL:**
```text theme={"dark"}
http://localhost:3000
```
*Override: Route to local dev server*
**Inject Headers:** Leave NULL
*Inherit from base (uses same header template)*
**Rate Limit:** Leave NULL
*Inherit from base*
**Require Signature:** Leave NULL
*Inherit from base*
Save development override.
#### Staging Override
1. Select **staging** environment
2. Configure:
**Target Base URL:**
```text theme={"dark"}
https://staging-api.production.com
```
*Override: Route to staging server*
**Inject Headers:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:staging_api_key}}"
}
```
*Override: Use staging API key instead of prod*
**Rate Limit:** Leave NULL or set different limit
*Can be inherited or overridden*
Save staging override.
### Step 4: Assign Environment-Specific Clients
Now assign different IP addresses per environment:
#### Development
1. Go to **Clients** tab
2. Select "development"
3. Assign your laptop/dev machine IP
4. Example: `dev-laptop` (203.45.67.89)
#### Staging
1. Select "staging"
2. Assign staging server IPs
3. Example: `staging-server` (34.56.78.90)
#### Production
1. Select "production"
2. Assign production server IPs
3. Example: `prod-server-1`, `prod-server-2`
## Use Environments in Requests
With the SDK, pass `environment` on the call; with raw curl, specify the `x-knoxcall-environment` header:
```typescript theme={"dark"}
const resp = await client.call("my-route", {
path: "/api/users",
environment: "development",
});
```
In the curl examples below, `$TOKEN` is a short-lived KnoxCall OAuth token — mint it once as shown in [Authentication](/api-reference/authentication).
### Development Request
```bash theme={"dark"}
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-route" \
-H "x-knoxcall-environment: development"
```
This routes to: `http://localhost:3000/api/users`
### Staging Request
```bash theme={"dark"}
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-route" \
-H "x-knoxcall-environment: staging"
```
This routes to: `https://staging-api.production.com/api/users`
### Production Request (Default)
```bash theme={"dark"}
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: my-route"
```
No environment header = uses base environment (production)
This routes to: `https://api.production.com/api/users`
## JavaScript Examples
### With Environment Switching
```javascript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const ENVIRONMENT = process.env.NODE_ENV; // 'development', 'staging', 'production'
const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET
// Only pass an environment for non-production (no environment = base environment)
const resp = await client.call("my-route", {
path: "/api/users",
...(ENVIRONMENT !== "production" && { environment: ENVIRONMENT }),
});
console.log(await resp.json());
```
### Environment-Specific Logic
Bind the route once per environment with `client.route()`, then use plain HTTP verbs:
```javascript theme={"dark"}
const devApi = client.route("my-route", { environment: "development" });
const prodApi = client.route("my-route"); // no environment = base environment (production)
const devUsers = await (await devApi.get("/api/users")).json(); // → http://localhost:3000/api/users
const prodUsers = await (await prodApi.get("/api/users")).json(); // → https://api.production.com/api/users
```
## View Environment Activity
### In Request Logs
1. Navigate to **Logs** → **API Logs**
2. See **Environment** badge on each request
3. Filter by environment
### In Analytics
1. Navigate to **Analytics**
2. Group by environment
3. Compare traffic across dev/staging/prod
## Best Practices
### Environment Naming
**Recommended names:**
* `development` (or `dev`)
* `staging` (or `stage`)
* `production` (or `prod`)
**Avoid:**
* Capital letters
* Spaces
* Special characters (except hyphens)
### Default Environment
**Always set production as default!**
If clients forget the environment header, they should hit production (not dev/staging).
This prevents accidental dev traffic in production apps.
### Environment Colors
Use colors for quick visual identification:
* 🔴 **Production**: Red (`#ff0055`) - be careful!
* 🟡 **Staging**: Yellow (`#ffaa00`) - caution
* 🟢 **Development**: Green (`#00ff88`) - safe to experiment
### Separate Secrets Per Environment
**Don't** use the same API keys for dev and production!
**Do** create environment-specific secrets:
* `stripe_dev_key`
* `stripe_staging_key`
* `stripe_prod_key`
### Different Rate Limits
**Development:** Unlimited (or very high)
* Fast iteration, no throttling during testing
**Staging:** Moderate (1000/min)
* Test rate limiting behavior
**Production:** Strict (100/min)
* Protect your backend
## Common Issues
### Environment Not Found
**Error:** `Environment 'staging' not found`
**Causes:**
* Typo in environment name
* Environment doesn't exist
* Environment name is case-sensitive
**Fix:**
* Check environment name spelling
* Verify environment exists in Environments page
* Use exact lowercase name
### Wrong Backend Reached
**Symptoms:** Development request hits production API
**Causes:**
* Missing `x-knoxcall-environment` header
* Typo in header value
* Environment override not configured
**Fix:**
* Add header: `x-knoxcall-environment: development`
* Check header spelling (lowercase)
* Verify environment override has correct target URL
### "IP not authorized" in New Environment
**Cause:** You created a new environment but didn't assign clients to it
**Fix:**
1. Go to route → **Clients** tab
2. Select the new environment
3. Assign clients (IP addresses)
4. Save
### NULL vs Empty String
**NULL (not set):** Inherits from base
**Empty string `""`:** Overrides with empty value
**Example:**
```text theme={"dark"}
Base: inject_headers = {"X-Key": "abc"}
Override with NULL: inject_headers = NULL
→ Result: {"X-Key": "abc"} (inherited)
Override with {}: inject_headers = {}
→ Result: {} (no headers injected)
```
## Advanced: Temporary Environments
For large features or hotfixes, create temporary environments:
**Feature branch:**
```text theme={"dark"}
feature-new-dashboard
```
**Hotfix:**
```text theme={"dark"}
hotfix-critical-bug
```
**After merging:** Delete the environment (no longer needed)
## Next Steps
Now that you have environments set up:
Learn safe deployment strategies
Create environment-specific secrets
Assign different IPs per environment
Configure different limits per environment
## Related Concepts
* **Base Route**: Default configuration (usually production)
* **Environment Override**: Environment-specific changes (NULL = inherit)
* **Default Environment**: Used when no header specified
* **Per-environment clients**: IP whitelist per environment
***
* **Level**: beginner
* **Time**: 10 minutes
`environments`, `configuration`, `deployment`, `quickstart`
# Creating Your First Route
Source: https://docs.knoxcall.com/getting-started/first-route
Create your first API route in KnoxCall. Learn how routes work and how to proxy requests to your backend.
# Creating Your First Route
Get your first webhook route up and running. A route forwards incoming requests from your clients to your backend API with added security and monitoring.
## What is a Route?
A **route** is a forwarding configuration that tells KnoxCall where to send incoming requests. Think of it as a smart proxy that:
* ✅ Validates API keys and IP addresses
* ✅ Injects secrets (API keys, OAuth tokens) into requests
* ✅ Logs all requests and responses
* ✅ Applies rate limiting
* ✅ Supports multiple environments (dev, staging, production)
## How Routes Work
Here's the request flow:
```text theme={"dark"}
Client Request
↓
POST https://a1b2c3d4.DunderMifflin.knoxcall.com/webhooks/order
Headers:
x-knoxcall-route: shopify-orders
Authorization: Bearer kc_... (OAuth token auth)
OR your IP is whitelisted (for IP-based auth)
↓
KnoxCall Route Selection:
- Subdomain identifies your TENANT (a1b2c3d4.DunderMifflin)
- x-knoxcall-route header selects the ROUTE ("shopify-orders")
↓
Validates access token OR IP address ✓
Checks client authorization ✓
Injects secrets: {{secret:shopify_api_key}}
↓
Forwards to: https://api.yourbackend.com/webhooks/order
↓
Response → Back to client
```
**Important**:
* **Subdomain** determines your tenant
* **`x-knoxcall-route` header** determines which route to use
* The URL path (`/webhooks/order`) is passed through to your backend unchanged
## Create Your First Route
### Step 1: Open Routes Page
1. Navigate to **Routes** in the sidebar
2. Click **Add Route**
### Step 2: Configure Basic Settings
**Route Name:**
```text theme={"dark"}
my-first-route
```
*Lowercase, use hyphens. This name is used in the `x-knoxcall-route` header to identify which route to use.*
**Target Base URL:**
```text theme={"dark"}
https://jsonplaceholder.typicode.com
```
*Your backend API's base URL. We'll use a test API for this example.*
**Base Environment:**
```text theme={"dark"}
production
```
*Default environment (already created for you)*
### Step 3: Security Settings (Optional for Now)
You can leave these as defaults:
* **Requires Clients**: ON (IP whitelist required)
* **Require Signature**: OFF
* **Rate Limiting**: OFF
We'll cover these in later guides.
### Step 4: Save
Click **Create Route** - your route is now live!
## Test Your Route
### Prerequisites
You need:
1. Your tenant subdomain (e.g., `a1b2c3d4.DunderMifflin.knoxcall.com`) — the SDK discovers this automatically
2. To be signed in: run `knoxcall login` once (or [create an OAuth client](/api-reference/authentication#create-an-oauth-client) under **Settings** → **API** → **OAuth Clients** and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`)
### Make a Request
**Option 1: Using the SDK** (recommended)
```typescript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login`
const resp = await client.call("my-first-route", { path: "/posts/1" });
console.log(await resp.json());
```
**Option 2: Using curl with an OAuth token**
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \
-H "x-knoxcall-route: my-first-route" \
-H "Authorization: Bearer $TOKEN"
```
**Option 3: Using IP-Based Authentication** (if your IP is whitelisted)
```bash theme={"dark"}
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \
-H "x-knoxcall-route: my-first-route"
```
**Required Headers:**
* `x-knoxcall-route`: The route name you created (REQUIRED)
* `Authorization: Bearer `: Your OAuth token (if not using IP-based auth). Legacy API keys go in `x-knoxcall-key` instead — never as a Bearer header on the proxy.
**URL Structure:**
* `https://a1b2c3d4.DunderMifflin.knoxcall.com` → Your unique tenant subdomain
* `/posts/1` → Path passed through to your backend
* Subdomain identifies your tenant, header identifies your route
### Expected Response
You should see JSON data from the test API:
```json theme={"dark"}
{
"userId": 1,
"id": 1,
"title": "sunt aut facere...",
"body": "quia et suscipit..."
}
```
This means your route is working! 🎉
## Understanding URL Mapping
KnoxCall forwards the entire path to your backend:
| Client Request | Target Base URL | Forwarded To |
| - | - | - |
| `/posts/1` | `https://api.example.com` | `https://api.example.com/posts/1` |
| `/api/v2/users` | `https://backend.com` | `https://backend.com/api/v2/users` |
| `/webhooks/stripe?id=123` | `https://app.com` | `https://app.com/webhooks/stripe?id=123` |
**Query parameters** are also passed through unchanged.
## View Request Logs
1. Navigate to **Logs** → **API Logs**
2. Find your request
3. Click to see full details:
* Request headers and body
* Response headers and body
* Response time
* Status code
* Source IP and geo-location
## Common Configuration Options
### Inject Headers
Add custom headers to every request:
```json theme={"dark"}
{
"X-API-Key": "{{secret:my_api_key}}",
"X-Custom-Header": "my-value"
}
```
Use `{{secret:name}}` to inject encrypted secrets from your vault.
### Inject Body Fields
Add fields to JSON request bodies:
```json theme={"dark"}
{
"api_key": "{{secret:printnode_key}}",
"metadata": {
"source": "knoxcall"
}
}
```
### Method-Specific Configuration
Configure different headers per HTTP method:
* **POST requests**: Inject write API key
* **GET requests**: Inject read-only API key
* **DELETE requests**: Require admin API key
This is configured in the **Method Configs** section.
## Security: Client IP Authorization
By default, `requires_clients: true` means only whitelisted IP addresses can use this route.
**To allow your IP:**
1. On your route, open the **Clients** tab and click **Add Client**
2. Pick an existing client from the list — or click **New client** (it is the first row, and the only button when you have no clients yet)
3. On the client form, enter:
* Name: "My Office"
* Type: "server"
* IP Address: Your IP (or CIDR range like `192.168.1.0/24`)
4. Click **Create Client** — you are taken straight back to your route with the new client selected
Now requests from your IP will work!
Your route form is kept while you create the client. The same goes for a new client or environment form: leave the page and come back, and what you had typed is restored (with a **Discard draft** link if you would rather start again). Drafts live only in the current browser tab and are dropped when it closes. A draft never keeps a credential: a header value typed in the clear (rather than as a `{{secret:...}}` reference) and anything in the target URL's query string are dropped, and the restored form tells you what to enter again.
**To disable IP authorization** (for testing):
* Edit your route
* Toggle **Requires Clients** to OFF
* Save
Disabling client IP authorization means anyone with a valid credential (OAuth token or API key) can call the route. Only do this for public routes.
## Common Issues
### "Route not found"
With a valid credential for your workspace, a Route that does not resolve answers
`404` with a JSON envelope naming the reason — `route_not_found` (the identifier
you sent is echoed back), `environment_not_configured` or `environment_disabled`
(the environment is named) — and `X-Knox-Origin: knoxcall` with no
`X-Knox-Upstream-Status`, so you can tell it from your provider's own 404s.
Without a valid credential the same request answers the opaque `401 Unauthorized`
below, on purpose: route names are not enumerable by anyone your workspace has
not authenticated.
**Check:**
* The `x-knoxcall-route` header value matches your route's slug, name or UUID exactly (case-sensitive)
* The route is enabled (toggle in route list), and the environment you named (or your default environment) is configured and enabled on it
* You're using the correct tenant subdomain — and, if you got a `401` rather than a `404`, that your credential belongs to this workspace
### "IP not authorized"
**Fix:**
* Add your IP address as a client (see "Client IP Authorization" above)
* Or disable `requires_clients` temporarily for testing
### "Invalid API key" / authentication errors
**Check:**
* Your OAuth token hasn't expired (tokens last 1 hour — the SDK refreshes automatically; re-mint for curl)
* Legacy keys only: you copied the full key (the complete `tk_..._...` string, not just the prefix shown in the dashboard) and it hasn't been deleted
* You're using the correct tenant subdomain
### "502 Bad Gateway"
**Causes:**
* Your target URL is unreachable from the internet
* The backend server is down
* DNS resolution failed for the target domain
**Debug:**
* Test the target URL directly: `curl https://your-backend.com/endpoint`
* Check your backend server logs
* Verify the target URL in route settings
### "Connection timeout"
**Causes:**
* Your backend is slow to respond (> 30 seconds default timeout)
* Network connectivity issues
* Backend server overloaded
**Fix:**
* Increase timeout in route settings
* Optimize your backend response time
* Check backend server resources
## Next Steps
Now that you have a working route:
Whitelist IP addresses with clients
Set up dev, staging, production
Add encrypted API keys to requests
Protect your backend from abuse
## Advanced Topics
* **[AI Route Setup](/advanced/ai-route-setup)**: Let AI configure routes from code or cURL commands
* **[Route Examples](/essentials/routes/route-examples)**: Real-world patterns for Stripe, Twilio, etc.
* **Request Signing**: HMAC-SHA256 signatures to prevent replay attacks
* **[Workflow Automation](/workflows/workflows-overview)**: Trigger workflows on route events
* **[Webhooks](/webhooks/webhooks-overview)**: Get notified of successes and failures
***
* **Level**: beginner
* **Time**: 5 minutes
`routes`, `quickstart`, `beginner`, `proxy`
# Creating Your First Secret
Source: https://docs.knoxcall.com/getting-started/first-secret
Store API keys and credentials securely with encrypted secrets. Learn how to inject secrets into requests server-side.
# Creating Your First Secret
Learn how to store sensitive credentials securely and inject them into backend requests without exposing them to clients.
## What is a Secret?
A **secret** in KnoxCall is an **encrypted credential** (API key, password, token) that's injected into your backend requests **server-side**. Your clients never see the actual value.
### Why Use Secrets?
**Without KnoxCall secrets:**
```javascript theme={"dark"}
// ❌ Frontend code - API key exposed in browser!
fetch("https://api.stripe.com/charges", {
headers: {
"Authorization": "Bearer sk_live_EXAMPLE_abc123xyz789..."
}
});
```
Anyone with dev tools can see your Stripe key! 😱
**With KnoxCall secrets:**
```javascript theme={"dark"}
// ✅ Frontend code - No secrets exposed
fetch("https://a1b2c3d4.DunderMifflin.knoxcall.com/api/payments", {
headers: {
"Authorization": `Bearer ${knoxcallToken}`, // short-lived OAuth token (kc_…)
"x-knoxcall-route": "stripe-payments"
}
});
```
KnoxCall injects your Stripe key server-side:
```text theme={"dark"}
Client Request → KnoxCall → Adds "Authorization: Bearer sk_live_abc..." → Stripe API
```
Your secret stays secure on KnoxCall's servers! 🔐
## Secret Types
KnoxCall supports three types of secrets:
### 1. API Key / String (Most Common)
Simple encrypted values:
* API keys (Stripe, SendGrid, Twilio)
* Passwords (database, services)
* Tokens (auth tokens, webhook secrets)
* Any sensitive string value
### 2. OAuth2 (Advanced)
Full OAuth2 flow with automatic token refresh:
* Google OAuth tokens (auto-refreshes when expired)
* Shopify, HubSpot, Microsoft, Slack OAuth
* Stores access token, refresh token, and expiry
* Handles token rotation automatically
### 3. Certificate
SSL/TLS certificates and private keys:
* SSL/TLS certificates for mutual TLS (mTLS) authentication
* Private keys for signing and encryption
* Client certificates for API authentication
We'll focus on **string secrets** in this guide.
## How Secrets Work
```text theme={"dark"}
1. You create secret in KnoxCall UI
↓
2. Value encrypted with AES-256-GCM
↓
3. Stored securely in encrypted vault
↓
4. You reference secret in route config: {{secret:stripe_key}}
↓
5. Client makes request through KnoxCall
↓
6. KnoxCall decrypts secret server-side
↓
7. Injects decrypted value into backend request
↓
8. Backend receives request with actual API key
↓
9. Client never sees the secret value!
```
**Encryption:** AES-256-GCM with envelope encryption (same security as AWS, Google Cloud)
## Create Your First Secret
### Step 1: Navigate to Secrets
1. Open the **Protect** section in the sidebar
2. Select **Secrets**
3. Click **Add Secret**
### Step 2: Choose Secret Type
Select **String Secret** (for this guide)
### Step 3: Fill in Details
**Secret Name:**
```text theme={"dark"}
stripe_prod_key
```
*Lowercase with underscores. This is how you'll reference it in templates.*
**Secret Value:**
```text theme={"dark"}
sk_live_EXAMPLE_abc123xyz789
```
*Paste your actual Stripe API key*
### Step 4: Save
Click **Create Secret**
The value is immediately encrypted using AES-256-GCM. You'll never see the plaintext value again in the UI!
**Copy your secret value elsewhere if you need it!** Once saved, KnoxCall only shows encrypted form. The plaintext cannot be retrieved (only used in requests).
## Use Secret in a Route
Now let's inject your secret into backend requests.
### Step 1: Edit Your Route
1. Navigate to **Routes**
2. Click your route (e.g., "stripe-payments")
3. Click **Edit**
### Step 2: Inject Secret in Headers
Find the **Inject Headers** section and add:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
**Template syntax:** `{{secret:name}}`
* `secret:` is required prefix
* `name` is your secret name (e.g., `stripe_prod_key`)
**Common header patterns:**
**Bearer token:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:api_key}}"
}
```
**API key header:**
```json theme={"dark"}
{
"X-API-Key": "{{secret:sendgrid_key}}"
}
```
**Basic auth:**
```json theme={"dark"}
{
"Authorization": "Basic {{secret:base64_credentials}}"
}
```
**Custom header:**
```json theme={"dark"}
{
"X-Webhook-Secret": "{{secret:webhook_secret}}",
"X-App-ID": "{{secret:app_id}}"
}
```
### Step 3: Or Inject Secret in Body
For POST/PUT/PATCH requests, inject into JSON body:
```json theme={"dark"}
{
"api_key": "{{secret:printnode_key}}",
"computerId": 123,
"options": {
"auth_token": "{{secret:auth_token}}"
}
}
```
Secrets work in nested objects too!
### Step 4: Save Route
Click **Save Changes**
## Test Your Secret
### Make a Request
With the SDK:
```typescript theme={"dark"}
const resp = await client.call("stripe-payments", {
method: "POST",
path: "/api/charge",
body: { amount: 1000, currency: "usd" },
});
```
Or with curl, using a short-lived OAuth token (see [Authentication](/api-reference/authentication)):
```bash theme={"dark"}
curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/charge" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: stripe-payments" \
-H "Content-Type: application/json" \
-d '{"amount": 1000, "currency": "usd"}'
```
### What Happens
1. KnoxCall receives your request
2. Loads route "stripe-payments"
3. Sees `{{secret:stripe_prod_key}}` in header template
4. Decrypts secret server-side
5. Replaces template with actual value:
```text theme={"dark"}
Authorization: Bearer sk_live_EXAMPLE_abc123xyz789...
```
6. Forwards request to Stripe API with real key
7. Returns response to you
**Your client never saw the Stripe key!** ✅
### View in Logs
1. Navigate to **Logs** → **API Logs**
2. Find your request
3. Click to expand details
4. Notice: Request headers show `{{secret:stripe_prod_key}}` (template)
5. But backend received actual decrypted value
Logs never expose plaintext secret values for security.
## Multiple Secrets in One Route
You can use multiple secrets in a single route:
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_key}}",
"X-Webhook-Secret": "{{secret:webhook_secret}}",
"X-User-ID": "{{secret:user_id}}"
}
```
Or in body:
```json theme={"dark"}
{
"payment_key": "{{secret:stripe_key}}",
"notification_key": "{{secret:twilio_key}}",
"database_url": "{{secret:postgres_url}}"
}
```
All secrets are decrypted and injected in a single pass.
## Environment-Specific Secrets
Use different secrets per environment (dev/staging/prod):
### Create Environment-Specific Secrets
1. **Development:**
* Name: `stripe_dev_key`
* Value: `sk_test_abc123...`
2. **Staging:**
* Name: `stripe_staging_key`
* Value: `sk_test_xyz789...`
3. **Production:**
* Name: `stripe_prod_key`
* Value: `sk_live_abc123...`
### Configure Route Per Environment
**Base route (production):**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
**Development override:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_dev_key}}"
}
```
**Staging override:**
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_staging_key}}"
}
```
Now each environment uses its own Stripe key automatically!
## Rotating a Secret
Updating a secret's value rotates it in place across every route that references it.
1. Navigate to **Secrets**
2. Click your secret (e.g., "stripe\_prod\_key")
3. In the **Details** tab, edit the secret value
4. Enter the new value:
```
sk_live_EXAMPLE_new789xyz123
```
5. Click **Save**
**What happens:**
* The encrypted value is replaced with the new one
* The new value takes effect immediately
* All routes using this secret now use the new value
* Zero downtime! No code changes needed
## Common Use Cases
### Payment APIs
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:stripe_prod_key}}"
}
```
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:paypal_access_token}}"
}
```
### Email/SMS Services
```json theme={"dark"}
{
"Authorization": "Bearer {{secret:sendgrid_api_key}}"
}
```
```json theme={"dark"}
{
"X-API-Key": "{{secret:twilio_auth_token}}"
}
```
### Webhooks
```json theme={"dark"}
{
"X-Webhook-Secret": "{{secret:github_webhook_secret}}"
}
```
```json theme={"dark"}
{
"X-Signature": "{{secret:shopify_hmac_secret}}"
}
```
### Database Credentials
```json theme={"dark"}
{
"db_url": "{{secret:postgres_connection_string}}"
}
```
### Printing Services
```json theme={"dark"}
{
"Authorization": "Basic {{secret:printnode_api_key}}"
}
```
## Security Best Practices
### Naming Conventions
✅ **Good names:**
```text theme={"dark"}
stripe_prod_key
sendgrid_dev_api_key
database_password_production
github_webhook_secret_staging
```
❌ **Bad names:**
```text theme={"dark"}
secret1
key
password
temp
```
Use descriptive names that indicate:
* Service (stripe, sendgrid)
* Purpose (api\_key, webhook\_secret)
* Environment (prod, staging, dev)
### Secret Rotation
**Recommended rotation schedule:**
* **Critical secrets** (payment APIs): Every 90 days
* **Less critical**: Every 180 days
* **Development**: Annually
**How to rotate:**
1. Generate new key in third-party service (Stripe, etc.)
2. Create new version in KnoxCall
3. Test in staging first
4. Deploy to production
5. Wait 24-48 hours
6. Revoke old key in third-party service
### Separation by Environment
✅ **Do:**
* Different secrets for dev/staging/prod
* Never use prod secrets in development
* Test secret rotation in staging first
❌ **Don't:**
* Reuse production secrets in development
* Share secrets across unrelated services
* Commit secrets to git (even encrypted)
### Access Control
* Only give team members access to secrets they need
* Use least privilege principle
* Audit secret access regularly
* Delete unused secrets promptly
### Monitoring
* Set up alerts for failed secret decryption
* Monitor secret usage in logs
* Track secret rotation dates
* Get notified when secrets expire
## Troubleshooting
### Secret Not Found Error
**Error:** `Secret 'stripe_key' not found`
**Causes:**
* Typo in secret name
* Secret was deleted
* Wrong syntax (forgot `secret:` prefix)
**Fix:**
1. Check secret exists: **Secrets** page
2. Verify name spelling matches exactly
3. Check template syntax: `{{secret:name}}` (not `{{name}}`)
### Template Not Replaced
**Symptoms:** Backend receives literal `{{secret:stripe_key}}` instead of actual value
**Causes:**
* Wrong template syntax
* Secret referenced but not found
* Header injection disabled
**Debug:**
1. Check logs - see what was actually sent
2. Verify template syntax is correct
3. Test secret exists and name matches
4. Check inject headers is enabled
### Wrong Value Injected
**Symptoms:** Backend gets wrong API key
**Causes:**
* Using wrong environment
* Secret not configured for environment
* Old version being used
**Fix:**
1. Check which environment request used
2. Verify secret configured for that environment
3. Check secret version (maybe need to create new version)
4. Look at logs to see which value was injected
## Next Steps
Now that you understand secrets:
Set up OAuth with auto-refresh
Advanced secret rotation & monitoring
Use different secrets per environment
Different secrets per HTTP method
## Related Concepts
* **Environment tab**: Different secrets per dev/staging/prod
* **Template System**: `{{secret:name}}` syntax for injection
* **Encryption**: AES-256-GCM with envelope encryption
* **OAuth2 Secrets**: Automatic token refresh for OAuth flows
***
* **Level**: beginner
* **Time**: 10 minutes
`secrets`, `security`, `credentials`, `encryption`, `quickstart`
# AI Onboarding Agent
Source: https://docs.knoxcall.com/getting-started/onboarding-agent
Automated setup powered by AI. Install a KnoxCall Agent on your host and let Claude analyze your codebase to automatically discover APIs, detect secrets, and create routes.
# AI Onboarding Agent
Skip manual setup. Install a KnoxCall Agent on your host, then start a conversation. The AI Onboarding Agent analyzes your codebase through the installed agent, discovers API integrations, detects credentials, and creates all necessary KnoxCall resources through a conversational interface.
The onboarding agent is a **cloud-only** feature (KnoxCall SaaS); it is not available on self-hosted deployments. SSH-based onboarding (entering a host, port, username, and SSH password/key) has been **retired** — you now install a lightweight KnoxCall Agent on your host and start a conversation bound to that agent.
## What is the Onboarding Agent?
The Onboarding Agent is a conversational AI assistant powered by Claude that:
* 🔍 **Scans your codebase** to find external API calls
* 🔑 **Detects secrets** in .env files and environment variables
* 🤖 **Suggests resources** (routes, secrets, clients) to create
* 💬 **Asks clarifying questions** when it needs your input
* ✨ **Creates everything automatically** once you approve
### How It Works
```text theme={"dark"}
1. Install KnoxCall Agent
↓ 1-line install on your host (Agents → "1-line install")
2. Start a Conversation
↓ Select repositories / code directories to analyze
3. AI Analysis
↓ Discovers endpoints, secrets, auth patterns
4. Review & Approve
↓ Agent shows findings, asks questions
5. Automatic Creation
↓ Routes, secrets, clients all created
```
## Quick Start
### Step 1: Install a KnoxCall Agent
1. Navigate to **Agents** in the admin UI
2. Click **1-line install** and copy the install command
3. Run the command on the host you want to onboard. The agent registers itself back to KnoxCall and appears in the Agents list as **active**.
You don't enter any SSH host, port, username, or password — the agent runs locally on your host and connects out to KnoxCall.
### Step 2: Start a Conversation
1. Navigate to **Onboarding** (or open the agent on the **Agents** page)
2. Click **New Conversation** and pick the installed agent you want to onboard
3. KnoxCall creates an onboarding session bound to that agent and opens the conversation page
Behind the scenes this calls `POST /admin/agents/:id/start-conversation`, which provisions the session and queues the agent to begin work.
### Step 3: Select Repositories
Browse the host's filesystem (via the installed agent) and select code directories to analyze:
```text theme={"dark"}
✅ /var/www/api-backend
✅ /home/deploy/microservices/payment-service
✅ /opt/apps/user-service
❌ /var/log (skip - no code here)
```
Click **Start Agent** to begin analysis.
### Step 4: Chat with the Agent
The agent will greet you and explain what it's doing:
```text theme={"dark"}
Agent: Hello! I'll scan your codebase to discover API integrations.
I've found 3 repositories to analyze. Starting scan...
Agent: Found 47 API calls in your code. Let me classify them...
Agent: I discovered calls to:
- Stripe API (15 endpoints)
- Twilio API (8 endpoints)
- SendGrid API (5 endpoints)
Are these external APIs you want to proxy through KnoxCall?
You: Yes, all of them
Agent: Great! I also found credentials in your .env files:
- STRIPE_SECRET_KEY
- TWILIO_AUTH_TOKEN
- SENDGRID_API_KEY
Should I create secrets for these?
You: Yes please
```
### Step 5: Review & Approve
The agent presents a summary of what it will create:
```yaml theme={"dark"}
Collections:
- Payment APIs (Stripe, PayPal)
- Communication APIs (Twilio, SendGrid)
Routes:
- 15 Stripe routes (charges, customers, subscriptions)
- 8 Twilio routes (SMS, voice, verify)
- 5 SendGrid routes (mail send, templates)
Secrets:
- stripe_secret_key (detected: API key)
- twilio_auth_token (detected: Auth token)
- sendgrid_api_key (detected: API key)
Client:
- Production Server (198.51.100.50)
```
Type `approve` to confirm, or ask questions to refine.
### Step 6: Automatic Creation
The agent creates everything:
```text theme={"dark"}
✅ Created collection: Payment APIs
✅ Created secret: stripe_secret_key
✅ Created route: Stripe Charges API
✅ Created route: Stripe Customers API
... (continues for all resources)
✅ Created client: Production Server
✅ Linked client to all routes
Done! Created 28 routes, 3 secrets, 2 collections, and 1 client.
```
## Features
### Intelligent Code Analysis
The agent uses Claude's AI to:
**Detect API Calls:**
```javascript theme={"dark"}
// Detected: Stripe API
const charge = await stripe.charges.create({
amount: 2000,
currency: 'usd',
source: token
});
// Detected: Twilio API
await twilioClient.messages.create({
to: phoneNumber,
from: config.twilioNumber,
body: message
});
```
**Classify Internal vs External:**
```javascript theme={"dark"}
// EXTERNAL - Will create route
fetch('https://api.stripe.com/v1/charges')
// INTERNAL - Skipped
fetch('/api/internal/users')
```
**Extract Authentication Patterns:**
```php theme={"dark"}
// Detected: Bearer token auth
$headers = ['Authorization: Bearer ' . $apiKey];
// Detected: Basic auth
curl_setopt($ch, CURLOPT_USERPWD, "$username:$password");
// Detected: API key in query
$url = "https://api.example.com/data?apiKey=" . $key;
```
### Secret Detection
Finds credentials in multiple formats:
**.env Files:**
```bash theme={"dark"}
STRIPE_SECRET_KEY=sk_live_abc123
TWILIO_AUTH_TOKEN=AC123456789
DATABASE_PASSWORD=supersecret
```
**PHP Config:**
```php theme={"dark"}
define('API_KEY', 'pk_live_xyz789');
$config['stripe_key'] = 'sk_test_123';
```
**JavaScript/TypeScript:**
```javascript theme={"dark"}
const SENDGRID_API_KEY = 'SG.abc123';
process.env.STRIPE_SECRET_KEY
```
**Python:**
```python theme={"dark"}
STRIPE_KEY = "sk_live_abc123"
api_key = os.getenv('TWILIO_AUTH_TOKEN')
```
The agent automatically:
* Suggests descriptive names (`stripe_secret_key` not `env_var_42`)
* Detects secret type (API key, OAuth token, password)
* Shows preview (first 4 chars) without exposing full value
* Groups secrets by service
### Conversational Interaction
The agent can:
**Ask clarifying questions:**
```text theme={"dark"}
Agent: I found calls to api.internal-analytics.com
Is this an external service or your own infrastructure?
You: That's our own service, skip it
Agent: Got it, I'll ignore api.internal-analytics.com
```
**Handle ambiguity:**
```text theme={"dark"}
Agent: The endpoint "POST /payments" is called with different
base URLs in different files:
- https://api.stripe.com (prod)
- https://api-sandbox.stripe.com (dev)
Should I create separate routes or use environments?
You: Use environments
Agent: Perfect, I'll create one route with production and
development environments
```
**Provide context:**
```text theme={"dark"}
You: Why do you need my Stripe key?
Agent: I detected 15 Stripe API calls in your code at:
- /var/www/api/payment.php (lines 45, 67, 89)
- /var/www/api/subscriptions.php (lines 23, 56)
The STRIPE_SECRET_KEY from your .env file will be stored
encrypted and injected when proxying these calls. You can
choose not to import it if you prefer manual setup.
```
### Pause & Resume
The agent saves its state, allowing you to:
* Disconnect and return later
* Review findings before proceeding
* Consult with your team
* Re-run analysis on the same server
## Advanced Usage
### Bulk Domain Classification
Instead of classifying each endpoint individually, classify by domain:
```text theme={"dark"}
Agent: I found endpoints from these domains:
🌐 api.stripe.com (15 endpoints)
EXTERNAL | High confidence | Payment processing
🌐 api.twilio.com (8 endpoints)
EXTERNAL | High confidence | Communication
🌐 internal-api.yourcompany.com (47 endpoints)
UNKNOWN | Needs your input
🌐 analytics.yourcompany.com (12 endpoints)
UNKNOWN | Needs your input
You: Stripe and Twilio are external. The others are internal, skip them.
Agent: ✅ Marked 23 external endpoints
⏭️ Skipped 59 internal endpoints
```
### Repository Re-Analysis
Start a new conversation against the same agent to re-scan the same host:
```text theme={"dark"}
Use case: You've updated your code and want to discover new APIs
without reinstalling anything.
1. Open the agent on the Agents page (or the Onboarding page)
2. Click "New Conversation"
3. Select the same repositories
4. Agent runs fresh analysis with updated code
```
### Custom Collections
Organize routes into logical groups:
```text theme={"dark"}
Agent: I can create these collections:
- Payment APIs (Stripe, PayPal)
- Communication (Twilio, SendGrid)
- Data Storage (AWS S3, Google Cloud Storage)
You: Create separate collections for each service
Agent: ✅ Will create 5 collections instead of 3
OR
You: Put everything in one collection called "Production APIs"
Agent: ✅ All routes in single collection "Production APIs"
```
### Multi-Environment Setup
The agent can configure environments during creation:
```text theme={"dark"}
Agent: I found these endpoint patterns:
Production: https://api.stripe.com
Sandbox: https://api-sandbox.stripe.com (in test files)
Create environments for both?
You: Yes
Agent: ✅ Creating routes with "production" and "sandbox" environments
You'll need to configure sandbox credentials later
```
## Security & Privacy
### Read-Only Access
The agent operates with **read-only filesystem access** on your host:
* ✅ Can list directories
* ✅ Can read file contents
* ❌ **Cannot** execute commands
* ❌ **Cannot** write files
* ❌ **Cannot** modify permissions
* ❌ **Cannot** delete anything
### Credential Handling
**Host Access:**
* KnoxCall never receives or stores SSH credentials for your host
* The KnoxCall Agent runs locally on your host and connects out to KnoxCall — KnoxCall never logs into your server
* The agent only reads the directories you select during the conversation
**Detected Secrets:**
* Full values **never** shown in UI
* Only first 4 characters displayed (preview)
* You must provide actual values during resource creation
* Agent cannot access production secret values
**Created Secrets:**
* Encrypted using AES-256-GCM
* Stored with per-secret encryption keys
* Keys encrypted with master key
* Zero-knowledge architecture
### Network Isolation
* The KnoxCall Agent runs on your host (not in your browser); the conversation orchestration runs server-side
* The agent connects out to KnoxCall — KnoxCall never opens an inbound connection to your server
* No third-party services involved
* No data sent to external APIs (except Claude for analysis)
### Audit Trail
All agent actions are logged:
```sql theme={"dark"}
-- Conversation history
SELECT * FROM onboarding_conversation_messages
WHERE session_id = 'your-session-id';
-- Files accessed
SELECT tool_input->>'path' FROM onboarding_conversation_messages
WHERE tool_name = 'read_file';
-- Resources created
SELECT created_route_id, created_secret_id
FROM onboarding_discovered_endpoints;
```
## Supported Languages & Frameworks
### Fully Supported
**Backend Languages:**
* JavaScript / Node.js
* TypeScript
* PHP
* Python
* Ruby
* Go
* Java
**Frameworks:**
* Express.js, Koa, Fastify (Node)
* Laravel, Symfony (PHP)
* Django, Flask, FastAPI (Python)
* Rails, Sinatra (Ruby)
* Gin, Echo (Go)
* Spring Boot (Java)
### Detected Patterns
**HTTP Clients:**
```javascript theme={"dark"}
// Node.js
fetch(), axios, got, request, superagent
// PHP
curl_exec(), file_get_contents(), Guzzle
// Python
requests, httpx, urllib, aiohttp
// Ruby
Net::HTTP, RestClient, Faraday, HTTParty
// Go
http.Client, resty
// Java
HttpClient, OkHttp, RestTemplate
```
**Environment Variables:**
```bash theme={"dark"}
# .env files
.env, .env.local, .env.production, .env.development
# Framework-specific
config/secrets.yml (Rails)
.env.php (Laravel)
settings.py (Django)
application.properties (Spring)
```
## Troubleshooting
### Connection Issues
**Problem: "Agent not connected" / "Agent offline"**
```text theme={"dark"}
Solution:
1. Confirm the KnoxCall Agent shows as "active" on the Agents page
2. Verify the agent process is running on your host
3. Confirm the host can reach KnoxCall outbound over HTTPS/WSS
4. Re-run the 1-line install if the agent was removed or revoked
```
**Problem: "Permission denied when reading files"**
```text theme={"dark"}
Solution:
1. Ensure the OS user the agent runs as has read access to code directories
2. Check: ls -la /var/www/yourapp
3. May need to add the agent's user to the www-data group:
sudo usermod -a -G www-data
```
### Analysis Issues
**Problem: "No endpoints discovered"**
```text theme={"dark"}
Possible causes:
1. Code uses indirect API calls (variables instead of strings)
2. Selected wrong directory (no source code)
3. Unsupported language/framework
Solution:
- Manually review files at: /var/www/yourapp/src
- Look for API client initialization
- Check if framework uses config files instead of inline URLs
```
**Problem: "Too many false positives"**
```text theme={"dark"}
Agent detected internal URLs as external APIs
Solution:
- Use bulk domain classification
- Mark internal domains as "skip"
- Agent learns: skips similar patterns
```
**Problem: "Secret values not detected"**
```text theme={"dark"}
Agent found .env files but no secrets
Possible causes:
1. Environment variables loaded at runtime (not in files)
2. Secrets stored in external vault (AWS Secrets Manager, etc.)
3. Config files in non-standard locations
Solution:
- Manually create secrets after onboarding
- Point agent to custom config directories
```
### Agent Behavior
**Problem: "Agent stopped responding"**
```text theme={"dark"}
Solution:
1. Check that the KnoxCall Agent is still active on the Agents page
2. Refresh page (agent state persists)
3. Send a message to wake agent
4. Check /var/www/knoxcall/logs for errors
```
**Problem: "Agent is stuck in a loop"**
```text theme={"dark"}
Agent keeps asking same question
Solution:
1. Answer with specific details:
❌ "yes"
✅ "Yes, all Stripe endpoints are external"
2. If unclear, ask agent: "What do you need from me?"
3. Last resort: restart session (data preserved)
```
## API Reference
### REST Endpoints
All onboarding endpoints live on the **admin host** (`admin.knoxcall.com`, or any `knoxcall.com` host), under the `/admin` prefix. They are authenticated by an admin **session JWT** plus the `X-Tenant-ID` header (owner/admin role) and are scoped to your tenant — they are **not** part of the `/v1/*` client API on `api.knoxcall.com` and do not accept an API key. They are also **cloud-only**: requests fail on self-hosted deployments.
**Start a Conversation (create session):**
```http theme={"dark"}
POST /admin/agents/{agentId}/start-conversation
{
"root": "/srv/app" // optional: filesystem root to scan
}
```
Returns `{ "session_id": "", "command_id": "" }`. This is the supported entry point — it creates an onboarding session bound to an installed KnoxCall Agent.
The legacy `POST /admin/onboarding/sessions` (passing `host`/`port`/`username`/`auth_type`) has been **retired** and now returns `410 Gone`. Install a KnoxCall Agent and use `POST /admin/agents/{agentId}/start-conversation` instead.
**Send Message:**
```http theme={"dark"}
POST /admin/onboarding/sessions/{id}/messages
{
"content": "Yes, create all routes"
}
```
**Stream Events (SSE):**
```http theme={"dark"}
GET /admin/onboarding/sessions/{id}/stream?token={jwt}
Events:
- agent.message: Agent sent a message
- agent.thinking: Agent is processing
- agent.tool_call: Agent using a tool (read_file, etc.)
- agent.tool_result: Tool execution completed
- agent.action_needed: Agent needs user input
- agent.error: Error occurred
- agent.complete: Session complete
```
### Event Types
**agent.message:**
```json theme={"dark"}
{
"id": "msg_abc123",
"content": "I found 15 Stripe API calls",
"sequence": 42
}
```
**agent.tool\_call:**
```json theme={"dark"}
{
"id": "tool_xyz789",
"toolName": "read_file",
"toolInput": {
"path": "/var/www/api/payment.php",
"offset": 1,
"limit": 100
}
}
```
**agent.action\_needed:**
```json theme={"dark"}
{
"id": "action_123",
"actionType": "approve_routes",
"message": "Should I create these 15 routes?",
"options": {
"routes": [...]
}
}
```
## Best Practices
### Before Starting
1. **Review your code** - Ensure .env files are up to date
2. **Install the KnoxCall Agent** - Run the 1-line install on your host and confirm it shows as active
3. **Backup credentials** - Agent won't access vault secrets; have them ready
4. **Clean up test code** - Remove old API integrations you're not using
### During Analysis
1. **Be specific** - "All Stripe endpoints are external" vs "yes"
2. **Group by service** - Create collections like "Payment APIs", not "APIs"
3. **Use environments** - One route with dev/prod environments, not separate routes
4. **Verify suggestions** - Agent's AI is smart but review before approving
### After Creation
1. **Test routes** - Use the testing UI to verify proxying works
2. **Configure secrets** - Add environment-specific secret values if needed
3. **Set rate limits** - Agent doesn't configure these; set manually
4. **Review client permissions** - Ensure client IP is correct
### Security Checklist
* [ ] Installed the KnoxCall Agent with a read-only OS user where possible
* [ ] Reviewed all discovered secrets before creating
* [ ] Verified no sensitive data in agent conversation
* [ ] Checked created routes target correct environments
* [ ] Tested with non-production credentials first
* [ ] Deleted onboarding session after completion (optional)
## Limitations
### What the Agent **Cannot** Do
* Execute commands on your server
* Modify your source code
* Access databases directly
* Read secrets from external vaults (AWS, HashiCorp)
* Detect API calls made via SDK without explicit URLs
* Parse compiled/minified code
* Read binary files
### Edge Cases
**Dynamic URLs:**
```javascript theme={"dark"}
// ❌ Agent cannot detect
const baseUrl = config.getUrl();
fetch(baseUrl + '/api/endpoint');
// ✅ Agent can detect
fetch('https://api.stripe.com' + '/v1/charges');
```
**Runtime Config:**
```python theme={"dark"}
# ❌ Agent cannot find these secrets
# (loaded from AWS Secrets Manager at runtime)
client = boto3.client('secretsmanager')
secret = client.get_secret_value(SecretId='prod/api/key')
# ✅ Agent can find these
# (.env file)
API_KEY=sk_live_abc123
```
**Indirect Calls:**
```php theme={"dark"}
// ❌ Hard to detect
$apiClient->call('charge.create', $params);
// ✅ Easy to detect
curl_exec($ch, "https://api.stripe.com/v1/charges");
```
## Next Steps
Manually create a route if agent missed one
Learn about secret encryption and environments
Configure dev, staging, and production
Restrict which clients can call routes
***
* **Level**: Beginner to Intermediate
* **Time**: 10-30 minutes (depends on codebase size)
* **Prerequisites**: A KnoxCall Agent installed on your host (cloud-only feature)
`onboarding`, `automation`, `ai`, `agent`, `setup`
# Provision with Terraform
Source: https://docs.knoxcall.com/getting-started/provision-with-terraform
The IaC path through onboarding: a route, its environments and its secret in ~40 lines of HCL, with a secret value that never enters your Terraform state.
# Provision with Terraform
Everything in the [quick start](/getting-started/quick-start-guide) can be done in the dashboard. This page is the same onboarding in HCL, for teams whose answer to "where is that configured?" has to be "in the repo".
By the end you will have a route, three environments with their own upstream configuration, and one secret whose value is not in your state file.
**The provider is not installable yet.** It has no released version: it is not on the Terraform Registry or the OpenTofu registry, and its source is not public, so you cannot run this page today. It documents the flow ahead of the release. To set up the same route, environments and secret now, follow the [quick start](/getting-started/quick-start-guide) in the dashboard. See the [Terraform provider guide](/sdks/terraform#install).
## Prerequisites
1. **Terraform >= 1.11 or OpenTofu >= 1.11.** Write-only attributes — the mechanism that keeps secret values out of state — do not exist below that in either tool.
2. **The provider** — not installable yet; see the notice above and the [Terraform provider guide](/sdks/terraform#install).
3. **An OAuth client for your tenant**, exported as `KNOXCALL_CLIENT_ID` and `KNOXCALL_CLIENT_SECRET`. Create one under **Settings → API**.
4. Your tenant slug, and the upstream credential you want KnoxCall to hold.
## The module
Around 40 lines. Copy it into `main.tf`, change the names and the upstream URL, and read the notes underneath before you apply.
```hcl theme={"dark"}
terraform {
required_version = ">= 1.11"
required_providers {
knoxcall = { source = "knoxcall/knoxcall" }
}
}
provider "knoxcall" {
# Reads KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET from the environment.
tenant_slug = "acme"
}
variable "stripe_key" {
description = "Upstream credential. Supply via TF_VAR_stripe_key; do not commit it."
type = string
sensitive = true
}
# "production" already exists as the tenant default, so declare only the others.
resource "knoxcall_environment" "env" {
for_each = toset(["development", "staging"])
name = each.key
}
# The value is write-only: Terraform sends it and forgets it.
resource "knoxcall_secret" "stripe" {
name = "stripe-key"
value_wo = var.stripe_key
value_wo_version = 1 # bump to rotate
}
# The route owns its BASE environment's configuration (production).
resource "knoxcall_route" "payments" {
name = "stripe-payments"
target_base_url = "https://api.stripe.com"
inject_headers_json = jsonencode({
Authorization = "Bearer {{secret_id:${knoxcall_secret.stripe.id}}}"
})
rate_limit_enabled = true
rate_limit_requests = 600
rate_limit_window_sec = 60
}
# Every other environment is its own row, with its own upstream and limits.
resource "knoxcall_route_environment" "payments" {
for_each = knoxcall_environment.env
route_id = knoxcall_route.payments.id
environment = each.value.name
target_base_url = "https://api.stripe.com"
inject_headers_json = jsonencode({
Authorization = "Bearer {{secret_id:${knoxcall_secret.stripe.id}}}"
})
rate_limit_enabled = true
rate_limit_requests = 60
rate_limit_window_sec = 60
}
```
Four things in there are worth understanding rather than copying:
* **`production` is not declared.** A route's `base_environment` defaults to `production`, and `knoxcall_route` owns that environment's configuration row itself. `knoxcall_route_environment` refuses the base environment for exactly that reason — two resources managing one row is how a module fights itself.
* **`{{secret_id:}}` is resolved server-side**, at proxy time. The header your upstream receives carries the real credential; the header your route *configuration* carries is a reference to a UUID. Nothing in Terraform ever holds the value.
* **`value_wo` is write-only and `value_wo_version` is the rotation trigger.** Terraform never sees a write-only value, so it cannot notice you changed one. Changing `value_wo` alone does nothing at all; the integer is what says "this is different now".
* **The `for_each` on `knoxcall_route_environment` iterates the environment resources**, not a list of names. That is what makes Terraform create the environment before the override that references it.
**Each environment needs its own value for the secret, and the provider cannot write those yet.** `knoxcall_secret.value_wo` sets the value for the secret's **base environment** — the tenant's default. A request carrying `x-knoxcall-environment: staging` looks up the staging value and, if nobody has set one, fails with
```
Secret 'stripe-key' (9c8b7a65-…) has no value configured for environment 'staging'
```
Add the other environments' values in the dashboard (**Secrets → your secret → Environments**) or through the API:
```sh theme={"dark"}
curl -X PUT "https://api.knoxcall.com/v1/secrets/$SECRET_ID/value?environment=staging" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value":"sk_test_..."}'
```
A per-environment value is not a Terraform resource today. That is a real gap, not a recommendation — it is stated here rather than left for you to discover on the first staging request.
## Apply
```sh theme={"dark"}
export KNOXCALL_CLIENT_ID=tk_live_...
export KNOXCALL_CLIENT_SECRET=...
export TF_VAR_stripe_key=sk_live_...
terraform init
terraform plan
terraform apply
```
## Call the route
The route is live as soon as the apply finishes:
```bash theme={"dark"}
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X GET "https://a1b2c3d4.acme.knoxcall.com/v1/charges" \
-H "x-knoxcall-route: stripe-payments" \
-H "Authorization: Bearer $TOKEN"
```
KnoxCall matched the route by the `x-knoxcall-route` header, decrypted the secret referenced by the route's injected `Authorization` header, and forwarded the request upstream with it. Your caller never held the Stripe key, and neither does your state file.
Add `-H "x-knoxcall-environment: staging"` and the same route resolves through the `staging` row instead — its own upstream URL, its own rate limit, its own value for the secret. Omit the header and you get the tenant's default environment.
## Rotate the secret
Rotation is one integer:
```diff theme={"dark"}
resource "knoxcall_secret" "stripe" {
name = "stripe-key"
value_wo = var.stripe_key
- value_wo_version = 1
+ value_wo_version = 2
}
```
`terraform apply` sends the new value and nothing else. The plan shows the version change, never the value. Server-side it is recorded as a `rotate` event in your tenant's audit log, which is where the history of a value Terraform cannot display actually lives.
Changing `value_wo` **without** bumping `value_wo_version` is inert: the new value is not sent, no diff appears, and the apply reports success. Drive both from one variable where you can.
## What ends up in your state file
The point of the module above is what is missing from `terraform.tfstate`: the Stripe key. A value you write into `value_wo` is read from your configuration, sent to the API, and nullified by Terraform before it can reach a plan or a state file — and KnoxCall's API never returns a stored secret value, so there is nothing to read back either.
Two things this does *not* cover, both stated in full on the [provider page](/sdks/terraform#secrets-and-terraform-state):
* Credentials **KnoxCall mints and hands back once** — a webhook's generated `secret_key`, an API key's plaintext, an OAuth client's secret, an issued client-certificate private key — are captured into state, because a value the server generates has to be `Computed`, and the plugin framework forbids `Computed` together with write-only. Each has a documented alternative; the webhook one is to sign with `hmac_key_id` instead, so no signing secret exists anywhere in Terraform.
* **A rotation performed outside Terraform is invisible to `plan`** unless you pin `value_version`. Terraform has no copy of the value to compare.
## Where to go next
* [Terraform provider guide](/sdks/terraform) — authentication modes, the sandbox data space, the full resource and import-ID map, and the client's retry/idempotency behaviour.
* [Creating your first route](/getting-started/first-route) — what a route actually does at request time.
* [Environments](/getting-started/first-environment) — how per-environment overrides resolve.
# Quick Start Guide
Source: https://docs.knoxcall.com/getting-started/quick-start-guide
Get started with KnoxCall in minutes. Follow our QuickStart wizard to create your first client, route, and secret.
# Quick Start Guide
Welcome to KnoxCall! This guide walks you through the same steps as our in-app QuickStart wizard. You'll learn the three core concepts of KnoxCall: **Clients**, **Routes**, and **Secrets**.
## What is KnoxCall?
KnoxCall is an **API gateway and reverse-proxy platform** that sits between your clients and your backend APIs. It provides:
* 🔐 **Security**: API keys, IP whitelisting, request signing, encrypted secrets
* 📊 **Monitoring**: Real-time logs, analytics, geo-tracking, alerts
* 🔧 **Flexibility**: Environment switching, secret injection, rate limiting
* 🌍 **Compliance**: VPN egress routing for data sovereignty (HIPAA, GDPR)
## The Three Core Concepts
Before you start, understand these three foundational concepts:
### 1. Clients = IP Addresses (Authorization)
**Clients** are authorized IP addresses that can call your routes. They provide network-level security.
**Think of it as:** "Which servers/devices are allowed to make requests?"
**Examples:**
* Your production server: `52.123.45.67`
* Your office network: `192.168.1.0/24`
* Your development laptop: `203.45.67.89`
### 2. Routes = API Proxies (Forwarding Rules)
**Routes** define how incoming requests are forwarded to your backend APIs.
**Think of it as:** "Where should KnoxCall send this request?"
**Example flow:**
```text theme={"dark"}
Client → KnoxCall Route → Your Backend API
```
### 3. Secrets = Encrypted Credentials (Security)
**Secrets** store API keys, tokens, and passwords encrypted on KnoxCall's servers. They're injected into requests server-side so clients never see them.
**Think of it as:** "How do I pass API keys to my backend without exposing them?"
## Step 1: Create Your First Client
Let's add your IP address as an authorized client.
### 1.1: Find Your IP Address
First, determine your current IP:
```bash theme={"dark"}
curl https://api.ipify.org
```
Copy this IP address (e.g., `203.45.67.89`)
### 1.2: Navigate to Clients
1. Expand **Proxy** in the sidebar
2. Select **Clients**
3. Click **Create Client** button
### 1.3: Fill in Client Details
**Client Name:**
```text theme={"dark"}
My Development Machine
```
**Type:** Select **User**
*For development laptops/workstations*
**IP Address:**
```text theme={"dark"}
203.45.67.89
```
*Paste your IP from step 1.1*
**Description** (optional):
```text theme={"dark"}
My laptop for testing KnoxCall
```
**Enabled:** ✓ ON
### 1.4: Save
Click **Create Client**
Your IP is now whitelisted and ready to make requests! ✅
**Production Tip:** For production servers, use **Server** type and static IP addresses (like AWS Elastic IPs). For development, **User** type is fine for dynamic IPs.
## Step 2: Create Your First Route
Now let's create a route to forward requests to a backend API.
### 2.1: Navigate to Routes
1. Expand **Proxy** in the sidebar
2. Select **Routes**
3. Click **Create Route** button
### 2.2: Configure Basic Settings
**Route Name:**
```text theme={"dark"}
test-api
```
*Lowercase with hyphens. You'll use this in requests.*
**Target Base URL:**
```text theme={"dark"}
https://jsonplaceholder.typicode.com
```
*We'll use a public test API for this tutorial*
**Base Environment:**
```text theme={"dark"}
production
```
*This is your default environment (already exists)*
**Enabled:** ✓ ON
### 2.3: Security Settings
Leave defaults for now:
* **Requires Clients:** ✓ ON (IP whitelist enabled)
* **Require Signature:** OFF
* **Rate Limiting:** OFF
We'll configure advanced security later.
### 2.4: Save
Click **Create Route**
Your route is now live! 🚀
### 2.5: Assign Your Client to the Route
Now connect your client (IP address) to your route:
1. Find your route in the list
2. Click on "test-api"
3. Go to **Clients** tab
4. Select "production" environment
5. Click **Assign Clients**
6. Check "My Development Machine"
7. Click **Save**
Your IP can now access this route! ✅
## Step 3: Create Your First Secret
Let's store a secret that can be injected into requests.
### 3.1: Navigate to Secrets
1. Expand **Protect** in the sidebar
2. Select **Secrets**
3. Click **Create Secret** button
### 3.2: Choose Secret Type
Select **String Secret**
*For API keys, tokens, passwords*
### 3.3: Fill in Secret Details
**Secret Name:**
```text theme={"dark"}
test_api_key
```
*Lowercase with underscores*
**Secret Value:**
```text theme={"dark"}
my-secret-api-key-12345
```
*For this tutorial, use any test value*
### 3.4: Save
Click **Create Secret**
Your secret is now encrypted and stored! 🔐
The secret value is encrypted immediately. You won't be able to see the plaintext again in the UI!
## Step 4: Make Your First Request
Now let's test everything together!
### 4.1: Sign In from Your Terminal
Authenticate your machine once — no keys to copy:
```bash theme={"dark"}
npm install @knoxcall/sdk@^1.0.0 # or: pip install "knoxcall>=1.0,<2.0" / gem install knoxcall -v "~> 1.0" / composer require knoxcall/sdk:^1.0 / go install github.com/knoxcall/sdk-go/cmd/knoxcall@v1.0.0
knoxcall login # opens your browser (PKCE); use `--device` on headless/SSH machines
```
`knoxcall login` stores a short-lived OAuth 2.1 credential in `~/.knoxcall/credentials.json`, and every KnoxCall SDK picks it up automatically.
For CI or servers, [create an OAuth client](/api-reference/authentication#create-an-oauth-client) instead (dashboard → **Settings** → **API** → **OAuth Clients**) and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. Long-lived API keys (`tk_…`) remain supported as a legacy alternative — see [Authentication](/api-reference/authentication).
### 4.2: Get Your Tenant Subdomain
When you created your account, KnoxCall generated a unique subdomain:
```text theme={"dark"}
https://{hash}.{your-slug}.knoxcall.com
```
Example: `https://a1b2c3d4.DunderMifflin.knoxcall.com`
Find yours in the admin UI header or your welcome email.
### 4.3: Make a Request
Now make your first proxied API call. With the SDK, one line does it:
```typescript theme={"dark"}
import { KnoxCall } from "@knoxcall/sdk";
const client = new KnoxCall(); // credentials from `knoxcall login`
const resp = await client.call("test-api", { path: "/posts/1" });
const post = await resp.json();
```
Prefer raw curl? Mint a short-lived OAuth token and send it as a Bearer header:
```bash theme={"dark"}
# Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication
TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \
-u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \
-H "Authorization: Bearer $TOKEN" \
-H "x-knoxcall-route: test-api"
```
**Replace:**
* `a1b2c3d4.DunderMifflin.knoxcall.com` → Your subdomain
* `test-api` → Your route name
Using a legacy API key instead? On the proxy it goes in `-H "x-knoxcall-key: tk_live_…"` — never as a Bearer header.
### 4.4: Expected Response
You should see JSON data from the test API:
```json theme={"dark"}
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident...",
"body": "quia et suscipit..."
}
```
**Congratulations! You just made your first request through KnoxCall!** 🎉
## Understanding What Happened
Here's the complete request flow:
```text theme={"dark"}
1. Your Request
↓
GET https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1
Headers:
Authorization: Bearer kc_... (Authentication — OAuth token)
x-knoxcall-route: test-api (Route selection)
↓
2. KnoxCall Receives Request
↓
- Validates access token ✓
- Loads route "test-api" ✓
- Checks IP authorization (your client) ✓
- Builds target URL: https://jsonplaceholder.typicode.com/posts/1
↓
3. KnoxCall Forwards Request
↓
GET https://jsonplaceholder.typicode.com/posts/1
↓
4. Backend Responds
↓
{ "userId": 1, "id": 1, ... }
↓
5. KnoxCall Returns Response to You
↓
6. Request Logged in KnoxCall Dashboard
```
## Step 5: View Your Request Logs
See your request in real-time:
1. Navigate to **Logs** → **API Logs**
2. Find your request (should be at the top)
3. Click to expand and see:
* Timestamp
* HTTP method and full URL path
* Status code (should be 200)
* Response time
* Request headers and body
* Response headers and body
* Source IP and geo-location
Real-time monitoring out of the box! 📊
## What You've Learned
✅ **Clients** - You created an authorized IP address
✅ **Routes** - You created a forwarding rule to a backend API
✅ **Secrets** - You created an encrypted credential
✅ **Authentication** - You signed in once with `knoxcall login` (OAuth 2.1)
✅ **Requests** - You made your first proxied API call
✅ **Logs** - You viewed request details in real-time
## Next Steps: Level Up
Now that you understand the basics, explore advanced features:
Add secrets to route headers/body
Set up dev, staging, production
Protect your APIs from abuse
Add HMAC signatures for security
View traffic metrics and trends
Get notified of failures
## Common Issues & Solutions
### "Route not found"
**Check:**
* You're using the correct subdomain (with hash prefix)
* The `x-knoxcall-route` header matches your route name exactly (case-sensitive)
* The route is enabled in the UI
### "Invalid API key" / authentication errors
**Check:**
* Your OAuth token hasn't expired (tokens last 1 hour — the SDK refreshes automatically; re-mint for curl)
* Your login is still valid (`knoxcall whoami`, then `knoxcall login` again if needed)
* If you use an OAuth client: it is active and `KNOXCALL_CLIENT_ID` / `KNOXCALL_CLIENT_SECRET` hold its Client ID and secret (**Settings** → **API** → **OAuth Clients**)
* If you use an OAuth client: it was created in the same mode (Live or Test) you are calling, and its **Allowed scopes** include `routes:invoke` for proxied calls
* Legacy keys only: you copied the full key and it hasn't been deleted
* You're using the correct tenant subdomain
### "IP not authorized"
**This is the most common issue for new users!**
**Causes:**
1. Your IP isn't whitelisted as a client
2. Client not assigned to the route environment
3. Client is disabled
4. Your IP changed (home networks, mobile)
**Fix:**
1. Check your current IP: `curl https://api.ipify.org`
2. Verify it matches your client IP in **Clients** page
3. Make sure client is assigned to route → **Clients** tab → "production"
4. Check client is enabled
**Quick test:** Temporarily disable `requires_clients` on your route to test if IP authorization is the issue.
### "502 Bad Gateway"
**Causes:**
* Target URL is unreachable from the internet
* Backend server is down
* DNS resolution failed
**Debug:**
* Test target URL directly: `curl https://jsonplaceholder.typicode.com/posts/1`
* Check backend server is running
* Verify target URL in route settings
## Pro Tips
### Tip 1: Use Sandbox (Test) Mode for Development
Develop against the isolated Test environment: create your OAuth client while the dashboard is in Test mode, and pass the SDK's `sandbox: true` option. Legacy test keys (`tk_test_…`) can skip IP authorization for easier development.
### Tip 2: Name Things Descriptively
**Good names:**
```text theme={"dark"}
production_server_1
office_network
stripe_prod_key
user_api_route
```
**Bad names:**
```text theme={"dark"}
client1
key
route
secret
```
### Tip 3: Start Simple, Add Security Later
1. First: Get basic route working
2. Then: Add IP authorization
3. Then: Add secrets
4. Then: Add rate limiting
5. Finally: Add request signing
Don't try to configure everything at once!
### Tip 4: Use the Support Chat
See that chat bubble in the bottom-right? That's powered by Chatwoot and monitored by our team. Ask questions anytime!
## Need Help?
* 💬 **Support chat** (bottom right corner) - fastest way to get help
* 📖 **Full documentation** - browse all guides and references
* 📧 **Email** - [support@knoxcall.com](mailto:support@knoxcall.com) for account issues
* 🐛 **Bug reports** - GitHub issues for technical problems
## Summary: The KnoxCall Workflow
```text theme={"dark"}
1. Create Clients (IP whitelist)
↓
2. Create Routes (forwarding rules)
↓
3. Create Secrets (encrypted credentials)
↓
4. Assign clients to routes
↓
5. (Optional) Inject secrets into routes
↓
6. Sign in (knoxcall login) or register an OAuth client
↓
7. Make requests with:
- the SDK: client.call(route, ...)
- or curl: Authorization: Bearer $TOKEN
plus x-knoxcall-route (route name)
↓
8. Monitor in real-time logs
```
**You're now ready to use KnoxCall!** 🚀
***
* **Level**: beginner
* **Time**: 15 minutes
* **Prerequisites**: None
`quickstart`, `beginner`, `tutorial`, `getting-started`, `wizard`
# Deployment Modes
Source: https://docs.knoxcall.com/infrastructure/deployment-modes
Choose between KnoxCall Cloud (managed) and self-hosted Docker, and between direct DNS routing or the Go Agent for zero-code-change onboarding.
# Deployment Modes
KnoxCall runs the same binary in our cloud and on your infrastructure. You choose **where the data plane lives** and **how your app reaches it** — two independent decisions, each with a sensible default.
## The 2×2 Matrix
| | **Direct URL / DNS** (code or DNS change) | **Via Go Client Agent** (no code change) |
| - | - | - |
| **KnoxCall Cloud proxy** (managed, default) | Combo 1 — easiest | Combo 2 — no code change |
| **Self-hosted `knoxcall/proxy` container** | Combo 3 — data-residency | Combo 4 — both |
Most customers start on **Combo 1** (KnoxCall Cloud + Direct). Upgrade to the Go Agent when changing base URLs is impractical. Move to self-hosted when regulation requires API payloads to stay on your infrastructure.
## Decision Tree
1. **Does regulation require API payloads to never leave your network?**
→ Yes: [Self-hosted proxy](/infrastructure/self-hosted-proxy)
2. **Can your app change its base URL or DNS records?**
→ Yes: direct routing (see [Getting Started](/getting-started/first-route))
3. **Otherwise** → [Install the Go Agent](/infrastructure/go-agent)
## Where Secrets Decrypt
This is the key differentiator between cloud and self-hosted:
* **KnoxCall Cloud mode**: your secrets are decrypted on our servers at request time. This is fine for 99% of customers — we never log plaintext and each secret is envelope-encrypted with a per-row key under our MASTER\_KEY\_B64.
* **Self-hosted mode**: your secrets decrypt on *your* infrastructure. The cloud control plane only holds them in their re-encrypted form (wrapped with a session key rotated hourly). No plaintext secret ever leaves your VPC in this mode.
## Control Plane vs Data Plane
Regardless of mode, the **control plane** — the admin UI, route definitions, secret storage, billing, analytics — always lives at knoxcall.com. What varies is the **data plane**: the service that handles your live API traffic.
```text theme={"dark"}
┌────────────────────────┐ ┌───────────────────────────┐
│ Control plane │ │ Data plane │
│ (always knoxcall.com) │ <──→ │ (cloud OR customer-run) │
│ Admin UI, routes, │ │ Handles live API traffic │
│ secrets (encrypted) │ │ │
└────────────────────────┘ └───────────────────────────┘
```
In cloud mode, the data plane runs on our infrastructure at `{tenant}.knoxcall.com`. In self-hosted mode, you run the same `knoxcall/proxy` Docker image on your infrastructure — the control plane still holds config and pushes it down via short-lived [session bundles](/infrastructure/self-hosted-proxy#session-bundles) every hour.
## Pricing
| Plan | Cloud (default) | Self-hosted option | Go Agent option |
| - | - | - | - |
| Free | ✅ | — | — |
| Starter | ✅ | — | — |
| Pro | ✅ | — | — |
| Enterprise | ✅ | ✅ | ✅ |
Self-hosted and the Go Agent are gated by the existing `byoAgentEnabled` feature flag, available on Enterprise only.
## Next Steps
* Install the [Go Client Agent](/infrastructure/go-agent) for zero-code-change onboarding
* Run the [Self-hosted proxy](/infrastructure/self-hosted-proxy) on your own Docker infrastructure
* Plan a [Cloud → Self-hosted migration](/infrastructure/migration) with shadow-mode testing
# Egress IP Routing & VPN
Source: https://docs.knoxcall.com/infrastructure/egress-routing
Route API requests through dedicated static IPs for whitelisting. Use managed KnoxCall egress servers or connect your own VPN infrastructure.
# Egress IP Routing & VPN
Many APIs require whitelisting specific IP addresses. KnoxCall supports routing requests through dedicated egress servers with static IPs—either managed by KnoxCall or through your own VPN infrastructure.
## The Problem
**Dynamic IPs:**
```text theme={"dark"}
Your server: 198.51.100.50
↓
KnoxCall proxy: 203.0.113.100 (changes)
↓
Partner API: ❌ Rejects (IP not whitelisted)
```
**APIs requiring IP whitelisting:**
* Banking and financial APIs (Open Banking, Plaid)
* Government services (tax, compliance)
* Enterprise B2B integrations
* Payment processors (some configurations)
* Internal corporate APIs
## The Solution
**Static egress IP:**
```text theme={"dark"}
Your server: 198.51.100.50 (any IP)
↓
KnoxCall proxy → Data plane node: 203.0.113.200 (fixed)
↓
Partner API: ✅ Accepts (203.0.113.200 is whitelisted)
```
**Benefits:**
* ✅ Single static IP to whitelist
* ✅ Works from any source (dev, staging, prod)
* ✅ Automatic failover if egress server down
* ✅ Choose geographic region
* ✅ Multiple egress IPs per tenant (if needed)
## Types of Egress Routing
KnoxCall supports two deployment models:
### 1. Managed Egress Servers (Recommended)
**What it is:**
* KnoxCall provisions and manages egress servers for you
* Static IP provisioned in your chosen region
* Automatic health monitoring and failover
* No infrastructure management required
**When to use:**
* You need a quick, turnkey solution
* You want KnoxCall to handle operations
* You don't have existing VPN infrastructure
**Regions available** (DigitalOcean region slugs):
* 🇺🇸 `nyc3` — New York, USA
* 🇺🇸 `sfo3` — San Francisco, USA
* 🇬🇧 `lon1` — London, UK
* 🇩🇪 `fra1` — Frankfurt, Germany
* 🇳🇱 `ams3` — Amsterdam, Netherlands
* 🇦🇺 `syd1` — Sydney, Australia
* 🇸🇬 `sgp1` — Singapore
**Pricing:**
* Included in Pro and Enterprise plans
* \$29/month per additional egress server (if you need multiple regions)
### 2. Self-Hosted VPN Integration
**What it is:**
* Connect KnoxCall to your existing VPN infrastructure
* Routes traffic through your VPN gateway
* You manage the VPN server, KnoxCall just uses it
**When to use:**
* You have existing VPN infrastructure
* Compliance requires self-hosted networking
* You need specific routing or firewall rules
* You want full control over egress traffic
**Supported VPN types:**
* HTTP forward proxy
* HTTPS forward proxy
Traffic reaches your network through an HTTP(S) forward proxy you run. SOCKS5,
WireGuard and OpenVPN endpoints are not dialled directly — put a forward proxy
in front of them. The proxy address must resolve to a publicly routable
address; loopback, private and link-local destinations are refused.
**Pricing:**
* Included in Enterprise plan
* Contact sales for availability on lower tiers
## Setting Up Managed Egress
### Step 1: Request Egress Server
1. Navigate to **Proxy** → **Static IPs** (cloud-hosted KnoxCall only — this page is hidden on self-hosted deployments)
2. Click **Request Static IP**
3. Configure:
```yaml theme={"dark"}
Type: shared # or "dedicated" (Enterprise only)
Region: nyc3 # DigitalOcean region slug — New York
```
4. Click **Request**
### Step 2: Wait for Provisioning
KnoxCall automatically:
* Creates a DigitalOcean Droplet in your region
* Configures forward proxy software
* Assigns a static IP (reserved IP)
* Sets up health checks
* Registers in data plane registry
**Typical provisioning time:** 2-3 minutes
### Step 3: Note Your Static IP
After provisioning completes:
```text theme={"dark"}
Egress Server: US East Egress
├─ Status: ✅ Active
├─ Region: nyc3 (New York, USA)
├─ Egress IP: 203.0.113.200
├─ Last seen: 12 seconds ago
└─ Routes using: 0
```
**Share this IP** with your API provider for whitelisting.
### Step 4: Assign to Routes
Configure which routes use the egress server:
**Option A: Per-environment (recommended)**
1. Go to route detail page
2. Select environment (e.g., "production")
3. Scroll to **Advanced** → **Egress Routing**
4. Select egress server: "US East Egress"
5. Save
**Option B: Via API**
```http theme={"dark"}
PUT /admin/routes/{route_id}/egress
{
"egress_server_id": ""
}
```
Pass `egress_server_id: null` to remove the egress assignment and revert the route to direct routing.
### Step 5: Test & Verify
Make a test request through your route:
```bash theme={"dark"}
curl https://yourapp.com/api/v1/proxy/your-route \
-H "Authorization: Bearer your-token"
```
Check the API provider's logs to confirm they see requests from `203.0.113.200`.
## Setting Up Self-Hosted VPN
### Prerequisites
* VPN server (WireGuard or OpenVPN)
* VPN configuration file or credentials
* Network access from KnoxCall servers
### Step 1: Prepare VPN Configuration
**WireGuard example:**
```ini theme={"dark"}
[Interface]
PrivateKey =
Address = 10.0.0.2/24
[Peer]
PublicKey =
Endpoint = vpn.yourcompany.com:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
```
**OpenVPN example:**
```text theme={"dark"}
client
dev tun
proto udp
remote vpn.yourcompany.com 1194
ca ca.crt
cert client.crt
key client.key
```
### Step 2: Register VPN in KnoxCall
1. Navigate to the **VPN** page and click **Create VPN Connection** → **My Own VPN**
2. Choose your VPN type (WireGuard, OpenVPN, or custom proxy)
3. Configure:
```yaml theme={"dark"}
Name: Corporate VPN
Type: wireguard
Configuration: (paste WireGuard config)
Egress IP: 203.0.113.200 (your VPN's egress IP)
Health Check URL: https://vpn-health.yourcompany.com/ping (optional)
```
4. Click **Save**
### Step 3: Test Connection
KnoxCall will:
* Establish VPN tunnel
* Perform health check (if configured)
* Report connectivity status
```text theme={"dark"}
VPN Node: Corporate VPN
├─ Status: ✅ Connected
├─ Type: WireGuard
├─ Egress IP: 203.0.113.200
├─ Last seen: 5 seconds ago
├─ Health: Healthy (200 OK)
└─ Routes using: 0
```
### Step 4: Assign to Routes
Assign the VPN node to a route from the route detail page under **Advanced** → **Egress Routing**.
## Advanced Configuration
### Multiple Egress Servers per Tenant
Use different egress IPs for different APIs:
```text theme={"dark"}
Egress Servers:
├─ US East Egress (203.0.113.200)
│ Routes: Stripe, Twilio
│
├─ EU West Egress (198.51.100.50)
│ Routes: European banking APIs
│
└─ Corporate VPN (192.0.2.100)
Routes: Internal partner APIs
```
**Configuration:**
* Route A (Stripe) → uses US East Egress
* Route B (European bank) → uses EU West Egress
* Route C (partner) → uses Corporate VPN
### Failover & High Availability
**Managed egress servers:**
* Automatic health monitoring (every 30 seconds)
* If unhealthy, routes fall back to direct routing
* Email alerts on failover
* Auto-recovery when health restored
**Self-hosted VPN:**
* Manual health check URL (optional)
* No automatic failover unless configured
* You're responsible for VPN uptime
### Regional Routing
Route requests through the closest region to the destination API:
```text theme={"dark"}
Your app in US → Stripe API in US
↓ uses US East Egress (low latency)
Your app in US → European Bank API in EU
↓ uses EU West Egress (compliance + latency)
```
## Security Considerations
### Managed Egress Servers
**Isolation:**
* Dedicated Droplet per egress server (not shared)
* Firewall rules restrict traffic to your routes only
* No direct SSH access (managed via API)
**Authentication:**
* Requests authenticated with tenant-specific secret
* HMAC signature verification
* Replay attack prevention
**Monitoring:**
* All traffic logged
* Health checks every 30 seconds
* Alerts on anomalies
### Self-Hosted VPN
**Security checklist:**
* [ ] VPN credentials stored encrypted in KnoxCall
* [ ] Firewall allows only KnoxCall server IPs
* [ ] VPN configuration doesn't expose internal network
* [ ] Health check endpoint authenticated (if public)
* [ ] Egress traffic monitored on your side
**Network isolation:**
* Ensure VPN tunnel doesn't grant KnoxCall access to internal network
* Use split tunneling (only route API traffic through VPN)
* Configure firewall rules on VPN gateway
## Monitoring & Troubleshooting
### Health Status
**Managed egress servers** — run an on-demand health check:
```http theme={"dark"}
GET /admin/egress/{server_id}/health
Response:
{
"server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"healthy": true,
"checked_at": "2025-01-20T15:30:00Z"
}
```
**Self-hosted VPN nodes** — read the node's current status:
```http theme={"dark"}
GET /admin/data-plane-nodes/{id}
Response:
{
"id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"status": "active",
"last_seen_at": "2025-01-20T15:30:00Z",
"health_check_url": "https://vpn-health.corp.com/ping",
"egress_ip": "203.0.113.200",
"region": "nyc3"
}
```
IDs are UUIDs. To actively test a self-hosted node's connectivity, call `POST /admin/data-plane-nodes/{id}/test`.
**Status values:**
* `active` - Healthy and routing traffic ✅
* `unhealthy` - Failed health check ⚠️
* `unreachable` - Cannot connect 🔴
* `unknown` - Status not yet determined 🔄
### Request Logs
View which requests used egress routing:
```http theme={"dark"}
GET /admin/logs
Response:
{
"logs": [
{
"route_id": "3a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"data_plane_node_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"egress_ip": "203.0.113.200",
"target_url": "https://api.stripe.com/v1/charges",
"status": 200,
"timestamp": "2025-01-20T15:30:00Z"
}
]
}
```
### Common Issues
**Problem: "Egress server unreachable"**
```text theme={"dark"}
Routes using this server fall back to direct routing
```
**Solutions:**
1. Check server status in dashboard
2. If managed: wait 2-3 min for auto-recovery
3. If self-hosted: verify VPN server is running
4. Check firewall allows KnoxCall IPs
**Problem: "API still rejecting IP"**
```text theme={"dark"}
API provider claims IP is not whitelisted
```
**Debug steps:**
1. Verify correct egress server assigned to route
2. Check route's environment is correct
3. Test with: `curl https://ifconfig.me/ip` through egress server
4. Confirm API provider whitelisted the right IP
5. Check for typos in IP address (common!)
**Problem: "Latency increased after enabling egress"**
```text theme={"dark"}
Requests now take 200ms longer
```
**Causes:**
* Egress server in wrong region (e.g., US egress for EU API)
* VPN has high latency
**Solutions:**
* Use egress server closest to destination API
* For self-hosted VPN: measure VPN latency
* Consider multiple regional egress servers
**Problem: "VPN connection keeps dropping"**
```text theme={"dark"}
Self-hosted VPN shows "unreachable" intermittently
```
**Solutions:**
1. Check VPN server logs for disconnections
2. Increase WireGuard PersistentKeepalive value
3. Configure health check to detect failures faster
4. Ensure firewall isn't blocking keep-alive packets
## Cost Optimization
### When to Use Egress Routing
**Use egress when:**
* ✅ API requires IP whitelisting
* ✅ You need consistent source IP for logging/audit
* ✅ Compliance mandates specific egress points
* ✅ API has regional restrictions
**Skip egress when:**
* ❌ API doesn't require IP whitelisting
* ❌ Added latency is critical (use direct routing)
* ❌ Route has very high traffic (cheaper to provision own server)
### Multi-Tenant Cost Sharing
For enterprise deployments with many routes:
```text theme={"dark"}
Option A: Separate egress per route
→ 10 routes = 10 egress servers = $290/month
Option B: Shared egress server
→ 10 routes = 1 egress server = $29/month
→ All use same egress IP
```
Most APIs accept multiple routes from same IP, so Option B is usually fine.
## API Reference
Managed egress servers have their own API surface under `/admin/egress`. The data-plane-node endpoints (`/admin/data-plane-nodes`) are for self-hosted VPN nodes. All of these endpoints are cloud-mode only.
#### Supporting endpoints
```http theme={"dark"}
GET /admin/egress/regions # list available DigitalOcean regions
GET /admin/egress/pricing # shared / dedicated monthly pricing
GET /admin/egress/availability/{region} # is a shared server available now?
```
### Request a Managed Egress IP
```http theme={"dark"}
POST /admin/egress
{
"type": "shared", // "shared" or "dedicated" (dedicated = Enterprise)
"region": "nyc3" // DigitalOcean region slug (default: syd1)
}
Response:
{
"ok": true,
"message": "No shared server available in this region. Provisioning new server (2-3 minutes).",
"is_provisioning": true,
"egress_server": {
"id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"ip_address": null,
"region": "nyc3",
"type": "shared",
"status": "provisioning"
}
}
```
Polling for completion:
```http theme={"dark"}
GET /admin/egress
Response (when ready):
{
"egress_servers": [
{
"id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"status": "active",
"reserved_ip": "203.0.113.200",
"region": "nyc3"
}
]
}
```
If provisioning fails, retry with `POST /admin/egress/{server_id}/retry`.
### Register Self-Hosted VPN
`region`, `kind`, and `display_name` are required. `kind` must be `managed` or `self_hosted`; `vpn_type` (when present) must be `wireguard`, `openvpn`, or `custom`. `vpn_config_json` is a free-form blob stored verbatim — its shape is not validated, so the keys below are just an example.
```http theme={"dark"}
POST /admin/data-plane-nodes
{
"kind": "self_hosted",
"region": "nyc3",
"display_name": "Corporate VPN",
"vpn_type": "wireguard",
"vpn_config_json": {
"interface": {
"private_key": "...",
"address": "10.0.0.2/24"
},
"peer": {
"public_key": "...",
"endpoint": "vpn.corp.com:51820",
"allowed_ips": "0.0.0.0/0"
}
},
"egress_ip": "203.0.113.200",
"health_check_url": "https://vpn-health.corp.com/ping"
}
Response:
{
"id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"status": "unknown",
...
}
```
The response returns the full node row; `id` is a UUID.
### Update / Test a Self-Hosted Node
```http theme={"dark"}
PUT /admin/data-plane-nodes/{id} # update node fields (partial; fields are COALESCEd)
POST /admin/data-plane-nodes/{id}/test # test VPN connectivity → { healthy, status, last_seen_at }
```
### Assign Egress Server to a Route
```http theme={"dark"}
PUT /admin/routes/{route_id}/egress
{
"egress_server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b"
}
```
Send `"egress_server_id": null` to clear the assignment and revert to direct routing.
### List Managed Egress Servers
```http theme={"dark"}
GET /admin/egress
Response:
{
"egress_servers": [
{
"id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"reserved_ip": "203.0.113.200",
"region": "nyc3",
"type": "shared",
"status": "active"
}
]
}
```
Self-hosted VPN nodes are listed separately via `GET /admin/data-plane-nodes` (returns `{ "nodes": [...] }`).
### Release a Managed Egress Server
```http theme={"dark"}
DELETE /admin/egress/{server_id}
Response:
{
"ok": true,
"message": "Egress IP allocation released"
}
```
A server cannot be released while any route still uses it (returns `400` with `routes_using`).
### Check Managed Egress Health
```http theme={"dark"}
GET /admin/egress/{server_id}/health
Response:
{
"server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b",
"healthy": true,
"checked_at": "2025-01-20T15:30:00Z"
}
```
## Best Practices
### ✅ Do This
1. **Test with dummy route first**
* Create test route, assign egress, verify IP
* Don't configure production routes immediately
2. **Document which API requires which IP**
* Keep spreadsheet of API → egress IP mappings
* Share with team for future reference
3. **Set up alerts**
* Email when egress server goes unhealthy
* Monitor latency changes
4. **Use regional egress servers**
* Match egress region to API region when possible
* Reduces latency
5. **Keep egress credentials secure**
* For self-hosted VPN, treat credentials as secrets
* Rotate VPN keys quarterly
### ❌ Avoid This
1. **Don't use same egress for unrelated APIs**
* If one API blocks your IP, all routes affected
* Use separate egress servers for high-risk APIs
2. **Don't skip health checks for self-hosted VPN**
* You'll only discover downtime when users complain
* Set up health check URL
3. **Don't delete egress server while routes use it**
* Routes will fall back to direct routing
* May break IP whitelisting
4. **Don't expose internal network via VPN**
* Use split tunneling
* Firewall VPN gateway appropriately
## Migration Guide
### From Direct Routing to Egress
**Before:**
```text theme={"dark"}
Route → Direct → API
(source IP changes)
```
**After:**
```text theme={"dark"}
Route → Egress Server → API
(source IP: 203.0.113.200)
```
**Steps:**
1. Provision egress server
2. Get static IP
3. Contact API provider to whitelist IP
4. Wait for confirmation (don't skip!)
5. Assign egress to route (test environment first)
6. Test
7. Assign to production
8. Monitor
**Rollback plan:**
* Clear the egress assignment (`PUT /admin/routes/{route_id}/egress` with `"egress_server_id": null`)
* Traffic reverts to direct routing
## Next Steps
Not yet available: how KnoxCall plans to run across regions
Learn about route setup
Monitor egress server health
Secure your routes
***
* **Level**: Intermediate to Advanced
* **Time**: 15-30 minutes
* **Prerequisites**: Understanding of IP whitelisting and networking
`egress`, `vpn`, `networking`, `ip-whitelisting`, `infrastructure`
# Go Client Agent
Source: https://docs.knoxcall.com/infrastructure/go-agent
Install the KnoxCall Go agent next to your app to route outbound API calls through KnoxCall with zero code changes.
# Go Client Agent
The Go agent runs next to your app as an HTTP(S) forward proxy on `localhost:7742`. It intercepts outbound calls to your KnoxCall-configured routes and transparently rewrites them through the KnoxCall data plane — **no code changes, no base URL swap**.
## When to Use It
* Your app is on infrastructure where changing DNS records is awkward (serverless runtimes with cached DNS, managed platforms, BYOD-host environments).
* You can't modify the SDK's base URL (vendor SDK that hardcodes production hosts).
* You want a drop-in ZTNA-style proxy without code changes.
If you *can* change base URLs, direct routing is simpler and faster — [skip the agent](/getting-started/first-route).
## Install
Generate agent credentials under **Automation → Agents** first, then install on each host that runs your app.
```bash Linux / macOS theme={"dark"}
curl -sSfL https://knoxcall.com/agent/install.sh | bash -s -- \
--agent-id=kc_agent_xxx \
--agent-secret=yyy \
--mode=intercept
```
```powershell Windows theme={"dark"}
iwr -useb https://knoxcall.com/agent/install.ps1 | iex
# then
knoxcall-agent --mode=intercept --agent-id=... --agent-secret=...
```
```yaml docker-compose theme={"dark"}
services:
knoxcall-agent:
image: knoxcall/agent:latest
restart: unless-stopped
network_mode: "host"
environment:
KNOXCALL_AGENT_ID: kc_agent_xxx
KNOXCALL_AGENT_SECRET: yyy
KNOXCALL_AGENT_MODE: intercept
```
## How Interception Works
The agent and the KnoxCall SDKs read the same answer: a Route whose **Intercept** toggle is on for the environment, from `GET /v1/wrap/intercept-manifest`. If you can change the process's code, the SDK's `intercept()` needs no agent, no listener and no certificate — see [Route-aware interception](/essentials/routes/route-aware-interception). The agent is for processes whose code you cannot change at all.
The agent generates a local CA certificate on first run and installs it in the system trust store. For every outbound HTTP(S) request:
1. The app's OS/SDK HTTP proxy setting points at `localhost:7742`.
2. The agent inspects the target host.
3. **Matched routes** (host is a registered KnoxCall route target) → MITM the TLS handshake with a locally-signed certificate, rewrite to the KnoxCall proxy URL with auth headers attached.
4. **Unmatched** → pass-through unchanged; the agent never sees non-KnoxCall traffic.
Your app code sees KnoxCall responses as if they came directly from the target API.
## Modes
The same binary runs in six modes depending on the positional argument:
| Mode | Purpose |
| - | - |
| `control` | Default. Persistent WebSocket to control plane; executes commands dispatched from the admin UI |
| `proxy` | Full proxy data plane (self-hosted / `knoxcall/proxy` container) |
| `intercept` | Long-running local MITM listener on `localhost:7742` |
| `onboard` | One-shot AI-powered codebase scanner for discovering API calls + secrets |
| `scan` | One-shot hardcoded-credential scanner (walks filesystem, env vars, TCP connections) |
| `doctor` | One-shot connectivity and config sanity check |
## Database Proxying
Database proxying is a capability of the long-running `intercept` (and `control`) modes — there is no separate positional mode for it. When the session bundle includes database routes, the agent binds one TCP listener per configured route on `127.0.0.1`. Applications connect to `127.0.0.1:{port}` as if connecting to the database directly — the agent handles all authentication to the upstream database and pipes the connection through.
This is used as part of [Secret Store Migration Bucket B](/essentials/migrations/bucket-b-databases) to migrate database credentials from cloud secret stores (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) into KnoxCall with zero credential exposure.
```bash theme={"dark"}
KNOXCALL_AGENT_ID=kc_agent_xxx \
KNOXCALL_AGENT_SECRET=yyy \
knoxcall-agent intercept
```
The agent reads its database route configuration from the KnoxCall session bundle (pushed from the control plane). Each route specifies: protocol (postgres/mysql/mongodb/redis/sqlserver), local port, upstream host/port, and credential secret reference.
See [Agent database proxy protocols](/essentials/migrations/agent-db-proxy) for wire-level details on each supported protocol.
## Tamper Seal
Every agent binary ships with a `build_sig` — an HMAC-SHA256 of the binary hash signed with a CI-only secret. The control plane validates this signature on every session fetch and refuses sessions for unknown builds in strict mode (`KNOXCALL_STRICT_TAMPER_CHECK=true`). In the default alert-only mode, unknown builds still get sessions but raise a visible warning in the admin UI.
This means a modified agent binary cannot simply reuse captured credentials — the control plane recognises it as foreign and refuses to issue the session key.
## Operational
* **Heartbeat**: every 60 seconds. Missing for >2 min = stale, >10 min = offline. Visible in Automation → Agents.
* **Session renewal**: in `intercept` mode the agent renews its bundle via `/agent/v1/session/renew` on the control plane, at the `next_renew_at` time returned in each bundle (typically before the \~1-hour session expiry). (The self-hosted `proxy` container instead re-fetches via `/agent/v1/session` on its own \~hourly cycle — see [Self-Hosted Proxy](/infrastructure/self-hosted-proxy).)
* **Credential rotation**: regenerate the agent secret from the Agents admin page and restart the agent — there is no downtime if you rotate before the old session expires.
* **Revocation**: revoking from the admin UI takes effect on the next session-renewal cycle (within \~5 minutes).
## Agent Detail Page
The **Automation → Agents → ** detail page shows:
* Identity & heartbeat state
* 7-day route activity (requests intercepted per route)
* Tamper events (if any build\_sig verifications failed)
* Revoke action
## Troubleshooting
* **"Certificate trust errors"**: the local CA didn't install. Re-run `knoxcall-agent install-ca` with admin/root.
* **"Connection refused on localhost:7742"**: agent crashed or exited. Check `journalctl -u knoxcall-agent` (Linux) or the Event Viewer (Windows).
* **"Session denied, tamper detected"**: the running binary's `build_sig` isn't in `agent_versions`. Download a fresh official binary.
# Migrating Between Cloud and Self-Hosted
Source: https://docs.knoxcall.com/infrastructure/migration
Cutover workflow for moving a tenant from KnoxCall Cloud to self-hosted Docker (and back), with shadow-mode testing and readiness gates.
# Migrating Between Cloud and Self-Hosted
This page covers switching a KnoxCall **tenant between Cloud and self-hosted deployment modes**. If you're looking to migrate secrets from AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager into KnoxCall, see the [Secret Store Migrations](/essentials/migrations/overview) section instead.
Switching a tenant between deployment modes is an in-place operation on `tenants.deployment_mode`. Cloud → self-hosted is gated by a readiness checklist; the reverse direction is always permitted.
## Switch Flow
1. **Set up the self-hosted proxy** on your infrastructure per the [self-hosted guide](/infrastructure/self-hosted-proxy). Container must be reachable over HTTPS.
2. **Configure the self-hosted proxy URL** under **Settings → Deployment**.
3. **Run the readiness checks** — all four must go green:
* Proxy reachable (HTTPS GET `/healthz` returns 200)
* Server-agent heartbeat healthy (under 2 minutes since last heartbeat)
* Shadow tests (24h) — at least one successful, zero mismatches
* Email provider configured (alerts, invitations, password resets)
4. **Confirm switch** — the button is disabled until the checklist passes or you explicitly force it.
5. **DNS cutover** — flip your client DNS from `tenant.knoxcall.com` to `proxy.yourdomain.com`. A 7-day cloud fallback is left warm in case you need to revert.
## Shadow Testing
Shadow mode lets you compare the cloud and self-hosted proxies side-by-side *before* cutover:
* **Settings → Deployment → Shadow test** fires a synthetic probe at both data planes and diffs the responses.
* Each run is recorded in `migration_diffs` (30-day retention).
* The readiness dashboard aggregates the last 24 hours of matches vs divergences.
Run several shadow tests covering the routes that matter most — auth routes, high-volume endpoints, routes with large request bodies — before flipping.
The first-cut shadow test pings `/healthz` on both sides. Route-level body comparison (sending the same authenticated request through both proxies and byte-diffing the response) is planned for a future release.
## Readiness Gates
The "Switch to Self-hosted" button is enabled only when all of these hold:
| Check | Threshold |
| - | - |
| Self-hosted proxy URL | Set, valid `http://` or `https://` |
| Proxy `/healthz` | Returns **200** within 5s |
| Server-agent heartbeat | under 2 min since last heartbeat (healthy) |
| Shadow test history | ≥1 test run, 0 mismatches in the last 24 hours |
| Email integration | An `email` row exists in `integration_configs` and a test send worked |
If you need to override (you've validated through another mechanism), the API accepts `{ force: true }` — the UI surfaces this as a "Force switch" confirm button when readiness is red.
## During Migration
Once you've confirmed the switch:
* `tenants.deployment_mode` flips to `self_hosted`.
* The cloud proxy starts returning `404` for any request addressed to this tenant.
* Your self-hosted proxy begins answering traffic.
* DNS change propagates (TTL-dependent — usually minutes).
* The cloud tenant record remains so control-plane operations (managing routes, viewing analytics) continue to work.
## Rolling Back
The reverse direction is one click:
1. **Settings → Deployment → Switch to KnoxCall Cloud**
2. DNS cutover from `proxy.yourdomain.com` back to `tenant.knoxcall.com`
There's no readiness gate on the reverse switch — the cloud proxy is always available for active tenants.
## Rate Limits
Shadow tests are rate-limited to **10 per minute per tenant** to protect the cloud proxy and your self-hosted container from admin-triggered load. Exceeding it returns `429` with a `Retry-After` header.
## Troubleshooting
* **Readiness check stays red despite reachable proxy**: confirm the public URL is what the control plane sees. A NAT or load-balancer rewriting paths will break `/healthz`.
* **`session_denied: tamper_detected`** in container logs: your `knoxcall/proxy` image's `build_sig` isn't in `agent_versions`. Pull `knoxcall/proxy:latest` and restart.
* **Alerts stopped after cutover**: check **Settings → Integrations → Email**. The self-hosted container doesn't inherit the cloud's email provider — you must configure your own.
* **"Bundle stale" 503 responses**: the container can't reach knoxcall.com. Check firewall / egress rules; the only outbound endpoint needed is `knoxcall.com:443`.
# Multi-Region Deployment (Planned)
Source: https://docs.knoxcall.com/infrastructure/multi-region
Planned, not yet available. KnoxCall serves every tenant from one region today; this page describes how multi-region deployment is intended to work.
# Multi-Region Deployment (Planned)
**Not available today.** KnoxCall runs in a single region (DigitalOcean, New York) and serves every tenant from it. There is no multi-region deployment, no cross-region failover, no regional data residency and no availability commitment attached to any of this yet. Everything below describes **planned** functionality and may change before it ships. If you are interested in multi-region support, contact our sales team to be notified when it becomes available.
When it ships, multi-region deployment is intended to run KnoxCall in several geographic regions, for lower-latency API access worldwide, data residency, and higher availability.
## Why Multi-Region?
### Performance
Reduce latency by routing requests to the nearest region:
```text theme={"dark"}
User in Tokyo → Asia-Pacific Region (50ms)
vs.
User in Tokyo → US East Region (200ms)
```
*Illustrative figures, not measurements.*
### Compliance
Meet data residency and sovereignty requirements:
* **GDPR**: EU data must stay in EU
* **CCPA**: California data preferences
* **HIPAA**: Healthcare data location requirements
### High Availability
The plan: if one region goes down, traffic would route to another. Today there is one region, so there is nothing to fail over to.
```text theme={"dark"}
US East: ❌ Down
↓
Automatic failover
↓
US West: ✅ Serving traffic
```
## Planned Regions
The planned region model defines four regions:
**Sydney, Australia (`nz`)**
* NZ + Australia data residency
* Serves NZ, AU
**Oregon, United States (`us`)**
* Americas + Asia coverage
* Primary region
**Frankfurt, Germany (`eu`)**
* GDPR-aligned EU data residency
* Serves UK + EU
**`global`**
* For tenants that opt out of regional pinning
* Served from any region
## How It Works
### Automatic Routing
Requests are planned to be routed to the nearest region:
```text theme={"dark"}
Client Location: London, UK
↓
DNS Resolution
↓
Nearest Region: EU West (Ireland)
↓
Request routed to EU West
↓
Low latency: ~20ms
```
### Data Synchronization
Your configuration is planned to be synchronized across all regions:
* **Routes**: Instantly synchronized
* **Secrets**: Replicated encrypted
* **Clients**: Available in all regions
* **Logs**: Aggregated centrally
### Region Selection
Three region-selection modes are planned:
#### 1. Automatic (Recommended)
KnoxCall would route to the nearest region:
```text theme={"dark"}
User → Closest Region
```
**Best for:** Most use cases
#### 2. Pinned Region
Pin a tenant to a specific region so its data is only served from that region. Region pinning is planned to be driven by the tenant's residency configuration (the `SERVER_REGION` of each regional deployment plus the tenant's assigned region), not a per-request header.
**Best for:** Data residency requirements
#### 3. Per-Route Region
Configure default region per route:
```text theme={"dark"}
Route: customer-data-api
Default Region: eu (GDPR compliance)
Route: global-api
Default Region: auto (nearest)
```
## Setting Up Multi-Region (Planned)
### Step 1: Enable Multi-Region (Enterprise)
Multi-region cannot be enabled on any plan today. The steps below describe the planned setup:
1. Contact sales or support
2. Enable multi-region for your tenant
3. Select regions to deploy to
### Step 2: Configure Routes
For each route, choose regional strategy:
1. Navigate to **Routes** → Select route
2. Go to **Regional Configuration** tab
3. Select mode:
**Automatic Routing:**
```text theme={"dark"}
Region Selection: Automatic
Fallback Region: us
```
**Pinned Region:**
```text theme={"dark"}
Region Selection: Pinned
Primary Region: eu
```
**Multi-Region Active-Active:**
```text theme={"dark"}
Region Selection: Active-Active
Regions: us, eu, au
Load Balancing: Geo-proximity
```
4. Save configuration
### Step 3: Test Regional Routing
Test from different locations by confirming requests resolve to the expected regional endpoint:
```bash theme={"dark"}
curl https://a1b2c3d4.acme.knoxcall.com/api/test \
-H "x-knoxcall-route: my-route" \
-v
```
A request from a US-based client is planned to resolve to the `us` (Oregon) deployment, while a request from an EU-based client resolves to the `eu` (Frankfurt) deployment, based on the tenant's residency configuration.
## Data Residency Configuration (Planned)
Planned route-level residency settings. None of them can be set today:
### GDPR Compliance (EU)
```yaml theme={"dark"}
Route: eu-customer-data
Regions: eu
Data Residency: Enforce EU only
Failover: Within EU only
```
### US Data Residency
```yaml theme={"dark"}
Route: us-customer-data
Regions: us
Data Residency: Enforce US only
Failover: Within US only
```
### Multi-Regional with Restrictions
```yaml theme={"dark"}
Route: global-api
Regions: All regions
EU Traffic: Must use the eu region
US Traffic: Must use the us region
NZ/AU Traffic: Must use the nz region
```
## Failover & High Availability (Planned)
### Automatic Failover (planned)
If a region becomes unhealthy, the planned behaviour is:
```text theme={"dark"}
Primary: US East → ❌ Health check fails
↓
Traffic switches to: US West ✅
↓
User requests continue uninterrupted
↓
When US East recovers → Traffic returns
```
### Health Checks
The planned health checks:
* **Frequency**: Every 10 seconds
* **Threshold**: 3 consecutive failures triggers failover
* **Checks**:
* API endpoint responsiveness
* Database connectivity
* Latency \< 1000ms
* Success rate > 95%
### Regional Status Page
Once multi-region ships, per-region health will be published on the status page.
The layout below is illustrative — KnoxCall serves from one region today, and no
per-region availability figure is published until there is one to measure:
🟢 **US East**: Operational
🟢 **US West**: Operational
🟢 **EU West**: Operational
🟢 **EU Central**: Operational
🟢 **APAC**: Operational
Visit: `https://status.knoxcall.com`
## Performance Benefits (Planned)
### Latency Reduction
Modelled round-trip times for the planned topology — these are illustrative, not
measurements. KnoxCall serves from a single region today:
| User Location | Single Region (US East) | Multi-Region | Improvement |
| - | - | - | - |
| New York | 20ms | 20ms | - |
| Los Angeles | 80ms | 25ms | 69% faster |
| London | 120ms | 15ms | 88% faster |
| Singapore | 250ms | 30ms | 88% faster |
| Sydney | 200ms | 50ms | 75% faster |
### Global Coverage Map
```text theme={"dark"}
🇺🇸 US West 🇺🇸 US East
25ms 20ms
╲ ╱
╲ ╱
User
╱ ╲
╱ ╲
15ms 30ms
🇪🇺 EU West 🇸🇬 APAC
```
## Monitoring Multi-Region (Planned)
### Regional Metrics Dashboard
Monitor performance per region:
* **Request volume per region**
* **Average latency per region**
* **Error rate per region**
* **Failover events**
### Alerts
Set up regional alerts:
```text theme={"dark"}
Alert: Region Degraded
Condition: Latency > 500ms for 5 minutes
Notification: Email, Slack, PagerDuty
Alert: Regional Failover
Condition: Traffic switched to backup region
Notification: Immediate SMS, Email
```
## Cost Considerations (Planned)
### Data Transfer
Pricing for multi-region, including any cross-region data transfer, has not been set.
**Optimization:**
* Pin routes to single region when possible
* Use multi-region only for global applications
## Best Practices (Planned)
### 1. Use Automatic Routing by Default
Unless you have specific compliance needs:
```yaml theme={"dark"}
Region Selection: Automatic
```
### 2. Pin Compliance-Sensitive Routes
For GDPR, HIPAA, or other compliance:
```yaml theme={"dark"}
Route: patient-data
Regions: us (HIPAA)
Data Residency: Enforce US only
```
### 3. Test Failover
Regularly test failover:
1. Simulate region failure
2. Verify traffic switches to backup
3. Ensure data consistency
### 4. Monitor Regional Performance
Set up dashboards per region:
* Latency trends
* Error rates
* Traffic distribution
### 5. Configure Appropriate Failover
**Critical routes:**
```yaml theme={"dark"}
Failover: Automatic, within 30 seconds
Backup regions: 2
```
**Non-critical routes:**
```yaml theme={"dark"}
Failover: Manual approval required
```
## Troubleshooting (Planned)
### High latency despite multi-region
**Check:**
* DNS propagation (24-48 hours)
* Regional assignment correct
* Client using correct endpoint
### Requests going to wrong region
**Causes:**
* DNS caching
* VPN routing
* Pinned region configuration
**Fix:**
* Clear DNS cache
* Verify the tenant's assigned residency region
* Verify route configuration
### Failover not working
**Check:**
* Health checks enabled
* Backup region configured
* Failover policy active
## Next Steps
Protect your APIs
Monitor regional health
Secure your routes
Monitor regional performance
***
* **Level**: advanced
* **Time**: 15 minutes
`multi-region`, `global`, `performance`, `compliance`, `high-availability`
# Cloud vs Self-Hosted Parity Matrix
Source: https://docs.knoxcall.com/infrastructure/parity-matrix
Every feature KnoxCall Cloud supports, and how the self-hosted knoxcall/proxy container compares. Same binary, same behaviour where possible — documented divergences where infrastructure limitations apply.
# Cloud vs Self-Hosted Parity Matrix
The design commitment: the self-hosted `knoxcall/proxy` Docker image is **the same binary** KnoxCall Cloud runs at `*.knoxcall.com`. The router logic, the secret-injection engine, the rate limits, the audit log, the alert monitor — identical code. Where behaviour diverges it's because the underlying resource is only available in one mode, and we document each exception here.
## Quick Summary
| Status | Meaning |
| - | - |
| ✅ **Full** | Same code path, same result — no customer work required. |
| 🔑 **Customer-provided** | Same code path; you supply the credential (SMTP, Twilio, S3, etc.). |
| ⚠️ **Reduced** | Works with a downgraded resource (e.g. free MaxMind vs paid); upgradeable with your key. |
| ☁️ **Cloud-only** | Feature depends on KnoxCall-operated infrastructure that self-hosted doesn't replicate. |
You can pull this as a live JSON document from `GET /admin/parity-audit` — the admin UI renders it under **Settings → Deployment → Feature parity**.
## Routing & Transformation
| Feature | Status | Notes |
| - | - | - |
| Route matching by subdomain / path | ✅ Full | Same resolver; tenant resolved from Host. |
| Secret injection (headers + body) | ✅ Full | Same Liquid template engine, same placeholder syntax. |
| Response transformation + log obfuscation | ✅ Full | Identical rules. |
| OAuth2 access-token refresh | ✅ Full | Refresh tokens re-encrypted in the session bundle, refreshed locally. |
| Rolling URL / request signing | ✅ Full | Same HMAC verification. |
| mTLS to upstream APIs | ✅ Full | Client certs re-encrypted in bundle, TLS terminated locally. |
| HTTP method restrictions | ✅ Full | Identical allowed-method enforcement per route/environment. |
| Per-route IP allowlist | ✅ Full | Client IP resolution identical (trust-proxy-1 CF headers). |
| Webhook dispatch | ✅ Full | Customer-defined URLs work the same. |
## Security
| Feature | Status | Notes |
| - | - | - |
| Per-route rate limiting | ✅ Full | Requires Redis — customer provides locally in self-hosted. |
| Per-tenant rate limiting | ✅ Full | Same. |
| Single-tenant enforcement (self-hosted) | ✅ Full | Self-hosted refuses foreign tenants; cloud serves all. |
| Binary tamper seal | ✅ Full | Same `build_sig` verified on every session fetch. |
| Machine-fingerprint binding | ✅ Full | Agent bound to its host on first fetch. [Details](/security/agent-security-model#machine-fingerprint-binding) |
| Nonce + timestamp replay protection | ✅ Full | Rejected if the same nonce is seen within the 10-min window. |
| Audit log hash chain | ✅ Full | Same schema + verification. |
## Analytics & Alerts
| Feature | Status | Notes |
| - | - | - |
| Per-request API logs | ✅ Full | Written locally on self-hosted; aggregated counts sync to control plane. |
| Alert monitoring + notification queue | ✅ Full | Same evaluator, delivery uses your integrations. |
| AI anomaly detection | 🔑 Customer-provided | BYO Anthropic API key under Settings → Integrations. |
| Real-time geo map | ⚠️ Reduced | Bundled MaxMind free DB; upgrade to your paid license for full accuracy. |
| Per-tenant usage metering | ✅ Full | Usage reported to control plane every 60s for admin-UI display. |
## Integrations
| Feature | Status | Notes |
| - | - | - |
| Email (alerts / invites / resets) | 🔑 Customer-provided | Resend / SendGrid / Postmark / Mailgun / SES / SMTP. **Required** on self-hosted. |
| SMS alerts (Twilio) | 🔑 Customer-provided | Customer Twilio credentials, identical delivery logic. |
| Response-body archival (S3/R2/MinIO) | 🔑 Customer-provided | Any S3-compatible endpoint. |
| Slack alert webhooks | ✅ Full | Configured per-webhook. |
| Stripe billing | ☁️ Cloud-only | Self-hosted plans are billed as a flat Enterprise license. |
| KnoxCall-managed DigitalOcean egress | ☁️ Cloud-only | Run your own egress proxy behind the container if needed. |
## Lifecycle
| Feature | Status | Notes |
| - | - | - |
| Migration wizard (cloud ↔ self-hosted) | ✅ Full | Readiness check + shadow test + one-click cutover in both directions. |
| Email branding + template overrides | ✅ Full | Same Settings → Integrations UI. |
| Team invitations + role changes | ✅ Full | Admin auth always flows through the control plane. |
## What "Same binary" Actually Means
* We build one Docker image per release: `knoxcall/proxy:{version}`.
* That image runs on our servers at knoxcall.com AND on your self-hosted infrastructure.
* The only difference is environment configuration: `KNOXCALL_DEPLOYMENT_MODE=cloud | self_hosted`, which tenant it serves, and which external credentials it's given.
* No forked codebase, no "Enterprise Edition", no minus-features stripped out. When we ship a new feature on cloud, it arrives in the next self-hosted image pull.
## Self-Hosted is Not a Subset
A common worry: "self-hosted versions of SaaS products are always stripped down." That's not our model. Self-hosted has **more** required configuration (you bring your own SMTP, Redis, Postgres, MaxMind, etc.) but the runtime behaviour is identical.
What the customer loses in self-hosted is:
* **The cloud operator doing ops for you** — you back up your own Postgres, rotate your own secrets, watch your own alerts. We still push the control-plane config (routes, secrets, feature flags) but the data plane is yours to operate.
* **Cross-tenant features we haven't yet made portable** — Stripe billing, KnoxCall-branded egress IPs, the global AI anomaly model trained across tenants. These stay cloud-only.
Everything else ships identical between the two.
## How We Enforce Parity
Three mechanisms:
1. **Source identity.** Both modes compile from the same `src/`. CI rebuilds the image on every merge.
2. **Shadow testing** during migration. The migration wizard dual-sends real requests through both sides and diffs responses before letting you cut over.
3. **This document.** Every known divergence is listed. If you find a feature that behaves differently and isn't in the Cloud-only row, that's a bug — email us.
See also: [Self-hosted proxy guide](/infrastructure/self-hosted-proxy), [Migration guide](/infrastructure/migration), [Agent security model](/security/agent-security-model).
# Self-Hosted Proxy
Source: https://docs.knoxcall.com/infrastructure/self-hosted-proxy
Run the KnoxCall data plane on your own Docker infrastructure. Same binary as the cloud — your payloads never leave your network.
# Self-Hosted Proxy
Self-hosted mode runs the same `knoxcall/proxy` Docker image on your infrastructure. Live API traffic — request bodies, response bodies, injected secrets — stays entirely within your VPC. Only routing metadata, tenant config, and re-encrypted secrets flow from the control plane over TLS.
Requires an **Enterprise** subscription with `byoAgentEnabled` (the same flag gates the Go Agent). Reach out if you need to pilot — we can provision a sandbox agent.
## Why Self-Host
* **Data-residency regulation**: HIPAA, FedRAMP-adjacent, EU-residency, sovereign-cloud contracts that require payload data to stay on-prem.
* **Air-gapped environments**: systems without outbound internet except to a single control-plane endpoint.
* **Latency-sensitive workloads**: proxy sits in the same VPC as your apps and upstream APIs.
* **Compliance audits**: every byte of customer data remains under your audit log.
## How It Works
```text theme={"dark"}
┌──────────────────────┐ HTTPS (session bundles,
│ KnoxCall cloud │ analytics, heartbeat)
│ (control plane) │ ◀─────────────────────────────┐
│ Admin UI, routes, │ │
│ secrets (encrypted) │ │
└──────────────────────┘ │
│
┌──────────────────────────────────────────────────────┤
│ Your infrastructure │
│ │
│ ┌────────────┐ ┌───────────────┐ ┌──────────┐ │
│ │ Your app │───▶│ knoxcall/ │──▶│ Upstream │ │
│ │ │ │ proxy │ │ API │ │
│ │ │ │ (docker) │ │ │ │
│ └────────────┘ └───────────────┘ └──────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ Postgres │ (your DB) │
│ │ Redis │ │
│ └───────────┘ │
└─────────────────────────────────────────────────────┘
```
* **Control plane** at knoxcall.com holds route config and MASTER\_KEY-wrapped secrets.
* **Your container** pulls a signed [session bundle](#session-bundles) every hour.
* **Request flow**: your app → `knoxcall/proxy` (your infra) → upstream API. Control-plane hop only happens during session-bundle refresh.
## Prerequisites
1. **Credentials**: create a server-mode agent under **Automation → Agents** with `mode: server`. Copy the `kc_agent_xxx` ID and secret (shown only once).
2. **Local Postgres + Redis**: one of each, reachable from the container. SQLite is not supported.
3. **Local MASTER\_KEY\_B64**: generate a 32-byte base64 key locally (`openssl rand -base64 32`). **Do NOT share this with KnoxCall**; it stays on your infrastructure and wraps integration credentials at rest.
4. **Outbound HTTPS** to `knoxcall.com` (for session-bundle refresh + metrics reporting).
5. **Public HTTPS endpoint** (typically `https://proxy.yourdomain.com`) behind your preferred TLS terminator.
## Install
### Docker Compose
Before bringing anything up, generate two strong secrets and place them in a local `.env` file (never commit):
```bash theme={"dark"}
# MASTER_KEY_B64 — stays on your infra; wraps integration credentials at rest
openssl rand -base64 32
# POSTGRES_PASSWORD — database credential; any long random string
openssl rand -base64 24
```
Create `.env` alongside the compose file:
```ini theme={"dark"}
KNOXCALL_TENANT_ID=
KNOXCALL_AGENT_ID=kc_agent_xxx
KNOXCALL_AGENT_SECRET=
MASTER_KEY_B64=
POSTGRES_PASSWORD=
```
Then the compose file:
```yaml theme={"dark"}
services:
proxy:
image: knoxcall/proxy:latest
restart: unless-stopped
ports: ["8080:8080"]
environment:
KNOXCALL_DEPLOYMENT_MODE: self_hosted
KNOXCALL_TENANT_ID: ${KNOXCALL_TENANT_ID}
KNOXCALL_AGENT_ID: ${KNOXCALL_AGENT_ID}
KNOXCALL_AGENT_SECRET: ${KNOXCALL_AGENT_SECRET}
KNOXCALL_CONTROL_PLANE_URL: https://knoxcall.com
PG_URL: postgres://kc:${POSTGRES_PASSWORD}@postgres:5432/kc
MASTER_KEY_B64: ${MASTER_KEY_B64}
REDIS_URL: redis://redis:6379
depends_on: [postgres, redis]
postgres:
image: postgres:16
environment:
POSTGRES_USER: kc
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: kc
volumes: ["./pgdata:/var/lib/postgresql/data"]
redis:
image: redis:7-alpine
```
`docker compose up -d`, then point your TLS terminator (Caddy, Nginx, Cloudflare Tunnel, ALB) at `:8080`.
### Health Checks
Once running, the container exposes:
* `GET /healthz` — readiness probe. Returns **503** until the first control-plane sync succeeds, then **200** with sync stats. Returns **503** again if the bundle goes stale (control plane unreachable for >1 hour).
* `GET /livez` — liveness probe. Always **200** while the process is responsive.
Point your orchestrator's readiness probe at `/healthz` so a container with a dead control-plane link is depooled automatically.
## Session Bundles
Every hour the container POSTs to `/agent/v1/session` with its agent credentials and receives a bundle containing:
* **Routes** merged with their production-environment config
* **Secrets** re-encrypted with an HKDF-derived session key (decrypts locally)
* **Tenant metadata** (slug)
* **Environments** and **API keys**
**The agent must be registered in `server` mode to receive any of this.** The bundle is shaped
by the registration's mode: a `server` registration (this container) gets the list above; an
`intercept` registration — the on-host MITM client — gets its routes, the data-plane URL and
its database-proxy listeners and **no secrets, no PKI material, no transit keys and no dynamic
database credentials at all**. A registration whose mode is anything else gets the narrow shape
too, so an omitted or unrecognised mode fails safe rather than handing out custody.
If your proxy starts but every route reports a missing secret, check the agent's mode on its
detail page. Switching an existing agent to proxy mode from that page sets it; so does creating
the agent with `mode: server`.
The session key never leaves the container. The MASTER\_KEY\_B64 on the control plane is never sent — secrets are re-encrypted per-window so even a packet capture during refresh yields nothing useful past the current hour.
### Field actions are not available on a self-hosted proxy
A route configured with **Relay field actions** (`tokenize`, `detokenize`, `encrypt`, `decrypt` on selected JSON fields) is **refused** by a self-hosted container, with `503` and `"code": "relay_unavailable"`. It is not served with the transform skipped.
Field actions resolve against the vault and ECDH key material held by the control plane, so a container cannot execute one. Forwarding the request anyway would send the exact value the action exists to remove — a card number, a national ID — upstream in the clear, and answer the upstream's normal `200`. Refusing is the only safe answer, and each refusal writes an audit row and pages KnoxCall operations.
If you need field actions on a route today, serve that route from the KnoxCall cloud data plane. Routes without field actions are unaffected.
Adding a field action to a route that a self-hosted container serves takes that route **out of service** on that container from the next hourly bundle refresh. Remove the action to restore it.
## Local Integrations
The control plane **never pushes KnoxCall's own integration credentials** (SMTP, Twilio, S3, Anthropic) to a self-hosted container. You configure your own under **Settings → Integrations**. Required minimum for a functional self-hosted deployment:
* **Email provider** — otherwise alerts, invitations, and password resets fail silently.
Optional:
* **S3** — for response-body archival
* **Anthropic** — for AI anomaly detection on your alerts
* **Twilio** — for SMS alerts
## Tamper Seal
Every `knoxcall/proxy` image is built with a two-pass `build_sig`:
1. Compile the image with a placeholder signature.
2. Hash the compiled `dist/` contents.
3. HMAC-SHA256 of `hash:version` with a CI-only secret.
4. Rebuild with the real signature baked in.
The control plane validates this on every session refresh. In strict mode (`KNOXCALL_STRICT_TAMPER_CHECK=true`), an unknown signature = session refused. Default mode logs a tamper event but still issues the session so you have time to investigate.
## Who can create a server-mode agent
A **server-mode** agent's secret is traded for this workspace's whole custody set (every secret the
proxy may inject), so handing one out is a reveal. These four dashboard actions are covered:
* creating an agent with `mode: "server"`;
* rotating the secret of a server-mode agent;
* creating an install token (the first step of `install.sh`);
* switching an agent to proxy mode (`switch_to_proxy`, or `switch_mode` to `proxy`), which makes
its next session a server-mode one.
In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The Management API (`/v1`) cannot create a server-mode agent; it is created in the dashboard. Creating or rotating an **intercept** agent, and switching an agent back to intercept or
control, are not reveals and are unchanged. The Owner is still emailed whenever an agent credential
is minted.
## Next Steps
* Read the [migration guide](/infrastructure/migration) to cutover from cloud to self-hosted with zero downtime
* Configure [email and other integrations](/infrastructure/self-hosted-proxy#local-integrations) on your self-hosted container
* Run [shadow-mode tests](/infrastructure/migration#shadow-testing) before flipping live traffic
# Make
Source: https://docs.knoxcall.com/integrations/make
Use KnoxCall in Make (Integromat) scenarios
The KnoxCall Make app brings KnoxCall into your visual scenarios, with instant triggers
and the security-forward actions.
## Create a connection
1. Add a KnoxCall module to a scenario and create a new connection.
2. Choose your **Environment** (Production or Sandbox).
3. Enter the **Client ID** and **Client Secret** from KnoxCall → **Security → API
credentials**.
The connection performs the OAuth client-credentials exchange, stores the token, and labels
itself with your tenant. Make refreshes the token automatically.
## Triggers (instant)
* **Watch Request Events** — one trigger, multi-select the lifecycle events you care about
(success, error, 4xx, 5xx, timeout, …), optionally filtered to a route.
* **Watch Audit Events (SIEM)** — every audit-log entry, for streaming into a SIEM or
notification channel.
Both are webhook (REST-hook) triggers: Make registers a webhook on enable and removes it on
disable. Deliveries are HMAC-signed.
## Actions
* **Proxy a Request** — call any API through KnoxCall with `{{token: ...}}` placeholders.
* **Tokenize / Detokenize**, **Encrypt / Decrypt / Sign / Sign JWT**.
* **Issue / Revoke Database Credential**, **Issue / Revoke Certificate**, **Rotate OAuth
Client Secret**, **Create Secret**.
* Plus full CRUD across routes, secrets, vaults, crypto keys, PKI, dynamic DB credentials,
webhooks, clients, environments, OAuth clients, API keys, and agents.
## Searches
Find Route / Get Route, Find / Get Secret, Get OAuth2 Secret Token, Get Crypto Public Key,
List Vaults & Tokens, List DB connections/roles/leases, List PKI roots / Get CRL, List
Audit Logs, Get Account / Usage, List Webhooks & logs, List Event Types, and more.
The Make app covers the full KnoxCall module catalog.
# n8n
Source: https://docs.knoxcall.com/integrations/n8n
Use KnoxCall in n8n workflows
`n8n-nodes-knoxcall` is a community node for [n8n](https://n8n.io/) — a great fit for
technical and security-minded teams running self-hosted automation.
## Install
In n8n, go to **Settings → Community Nodes → Install** and enter:
```
n8n-nodes-knoxcall
```
Self-hosters can also `npm install n8n-nodes-knoxcall` in their n8n custom-nodes directory.
## Credentials
Create a **KnoxCall API** credential: choose your **Environment** (Production or Sandbox)
and paste the **Client ID** and **Client Secret** from KnoxCall → **Security → API
credentials**. The credential performs the OAuth client-credentials exchange; its test
calls `GET /v1/account`.
## Nodes
**KnoxCall** (action node) — pick a resource and operation:
* **Proxy** → Send Request (inject secrets with `{{token: ...}}`).
* **Vault** → Tokenize, Detokenize.
* **Crypto Key** → Encrypt, Decrypt, Sign, Sign JWT, Get Public Key.
* **Database Credential** → Issue, Revoke.
* **Secret** → Create, Get OAuth2 Token.
* **OAuth Client** → Rotate Secret.
* **Certificate (PKI)** → Issue, Revoke.
* **Route / Audit Log / Account / Webhook** → list/get reads.
* **Environment / Client / API Key / Agent** → create and list management resources.
**KnoxCall Trigger** (trigger node) — starts a workflow on KnoxCall events. One
multi-select **Events** field covers all `request.*` events plus `audit.event`; optional
route filter; HMAC signature verification built in.
**KnoxCall Polling Trigger** (trigger node) — polls for New Audit Log, New Route, New
Secret, New Database Lease, and New Webhook Delivery.
The n8n node covers the full KnoxCall module catalog.
# Automation Platforms
Source: https://docs.knoxcall.com/integrations/overview
Connect KnoxCall to Zapier, Make, and n8n
KnoxCall has native connectors for the major automation platforms, so non-developer teams
can wire KnoxCall into thousands of apps without writing code.
5,000+ apps. Comprehensive coverage across every KnoxCall resource.
Visual scenarios. Full module coverage across every KnoxCall resource.
Self-hostable, open-source. Community node with full module coverage.
## What makes these connectors different
Most "secrets" integrations just read and write values. KnoxCall's connectors are built so
the secret never has to enter the automation platform:
* **Proxy a Request** — call any third-party API *through* KnoxCall. Reference stored
secrets and vault tokens with `{{token: ...}}` placeholders; the real values are injected
server-side. Your API keys never touch Zapier/Make/n8n.
* **Tokenize / Detokenize** — swap PII (card numbers, SSNs, emails) for vault tokens so raw
values never sit in an automation's data store.
* **Encrypt / Decrypt / Sign / JWT** — encryption-as-a-service from a single step.
* **Issue short-lived DB credentials** and **certificates** on demand.
## Authentication (all platforms)
Every connector authenticates with **OAuth client credentials**. In KnoxCall, go to
**Security → API credentials** and create an OAuth client; copy its **Client ID** and
**Client Secret** into the connector along with your **Environment** (Production or
Sandbox). The connector exchanges them for a short-lived token and auto-discovers your
tenant — you never type your slug.
DPoP (sender-constrained tokens) is not used by these connectors. If your OAuth client
requires DPoP, the connector will tell you — disable DPoP on that client or use a
different one.
## Triggers
Triggers are powered by KnoxCall webhooks. Subscribe to the request lifecycle
(`request.success`, `request.error`, `request.client_error`, …) or to `audit.event` to
stream every audit-log entry into Slack, a SIEM, or a spreadsheet. Deliveries are
HMAC-signed; the connectors verify the signature for you.
# Anthropic
Source: https://docs.knoxcall.com/integrations/setup/anthropic
Bring your own Anthropic API key to KnoxCall — what the workspace-scoped key powers (anomaly analysis, spec analysis, the workflow copilot), how to scope it with a Workspace and spend limit, and the difference from AI Gateway upstream keys.
# Anthropic
KnoxCall's AI features run on Claude. Which key they use depends on the scope:
| Scope | What the key powers |
| - | - |
| **This tenant** | Everything AI that KnoxCall does *for your workspace*: alert anomaly analysis, OpenAPI spec analysis, collection analysis, the workflow copilot, and the AI Gateway's system agent. **There is no fallback to a platform key** — until you save your own, those features tell you a key is required. Your usage is billed to your Anthropic account, never to KnoxCall's. |
| **Global defaults** | The platform's own AI: the support assistant, blog autopilot and AI-visibility probes. On KnoxCall Cloud this is KnoxCall's key; a self-hosted deployment that wants those features saves its own here. |
This is **not** where the AI Gateway gets keys for the models it proxies. Upstream provider keys for the gateway are attached to the gateway itself — see [Bring your own key](/ai-gateway/byo-key).
## 1. In the Anthropic Console
Anthropic API keys carry no scopes; you scope them by **Workspace**, which also isolates rate limits and spend.
1. [console.anthropic.com](https://console.anthropic.com) → **Settings → Workspaces → Create workspace**, dedicated to KnoxCall.
2. On that workspace set a **monthly spend limit** — the key is used by unattended analysis, not only by people clicking buttons.
3. **Settings → API keys → Create Key**, attached to that workspace. Copy the value (`sk-ant-api03-…`) — shown once.
## 2. Enter it in KnoxCall
**Settings → Integrations** → scope → **Anthropic** → *Configure*:
| Field | Value |
| - | - |
| **API key** | `sk-ant-…` |
| **Model** *(optional)* | Leave blank. KnoxCall picks a current model centrally and moves it forward when a new one ships; set this only to pin a specific model id. |
**Save credentials.**
## 3. Verify
Open an alert and run **AI analysis**, or ask the workflow copilot for a suggestion. A wrong key fails there with an authentication error from Anthropic. The operator console's *Test connection* lists models with the stored key.
## Things that bite
* **Rotation.** Create the new key, save it here, then delete the old one in the console. The form keeps the stored key when the box is left blank, so a public-only edit (changing the model) never drops it.
* **Self-hosted environment fallback.** For the Global defaults row, a stored key wins over `ANTHROPIC_API_KEY`; the variable is used only when no row exists.
* **Spend limits are yours to set.** KnoxCall applies its own per-workspace AI rate limits, but the bill is on your account.
## Related
* [AI threat detection](/security/ai-threat-detection) — what anomaly analysis looks for
* [Workflows — AI](/workflows/ai) — the copilot and AI steps
* [OpenAI](/integrations/setup/openai) — the platform fallback provider
# AWS Operator Identity (BYOK)
Source: https://docs.knoxcall.com/integrations/setup/aws-operator-identity
The AWS identity KnoxCall uses to assume customer KMS roles for Bring-Your-Own-Key — an IAM user with sts:AssumeRole scoped to BYOK role ARNs and nothing else, and how it differs from the master-key unseal identity.
# AWS Operator Identity (BYOK)
When a workspace brings its own AWS KMS key ([tenant KMS setup](/essentials/tenant-kms/setup-aws)), KnoxCall does not hold that workspace's AWS credentials. It holds **one identity of its own** and assumes the role the workspace created, and the workspace's key policy trusts exactly that identity. This card is that identity.
**Scope: Global defaults** only. On KnoxCall Cloud it is KnoxCall's; the BYOK wizard shows workspaces the principal to trust. A self-hosted deployment that offers BYOK to its workspaces configures its own here.
This is **not** the master-key unseal identity. Rotating or deleting it cannot lock the deployment out of its master key; it affects only workspaces' BYOK operations.
## 1. In AWS IAM
1. **IAM → Users → Create user**, no console access.
2. Attach an inline policy with exactly two actions:
```json theme={"dark"}
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" },
{ "Effect": "Allow", "Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/knoxcall-byok-*" }
]
}
```
`GetCallerIdentity` lets KnoxCall display which principal workspaces should trust. `AssumeRole` is restricted by resource to the BYOK role naming pattern — never `*`. **No `kms:*` on this identity**: KMS permissions are granted by each workspace to the *assumed* role, which is the whole point of BYOK.
3. **Security credentials → Create access key → Application running outside AWS**. Copy both halves.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → AWS operator identity (BYOK)** → *Configure*:
| Field | Value |
| - | - |
| **Access key ID** | `AKIA…` |
| **Secret access key** | The secret half |
| **Default region** *(optional)* | e.g. `us-east-1` |
**Save credentials.**
## 3. Verify
Open a workspace's **Settings → Security → Tenant master key** and start the AWS BYOK wizard: it shows the resolved principal (a `GetCallerIdentity` call with this identity). Completing a BYOK setup end-to-end proves `AssumeRole`.
## Things that bite
* **When no row is stored, the AWS SDK default chain is used** (environment, shared config, instance metadata) — so "not configured" does not mean "no AWS identity in play" on a self-hosted host.
* **Rotation is two-sided only in one direction.** Workspaces trust the *user's ARN*, not its key, so rotating the access key needs no change on their side; deleting the user does.
## Related
* [BYOK and KMS](/essentials/key-management/byok-and-kms)
* [Tenant KMS — AWS setup](/essentials/tenant-kms/setup-aws)
# Chatwoot (Support Bot)
Source: https://docs.knoxcall.com/integrations/setup/chatwoot
Connect Chatwoot to KnoxCall for the support widget and the assistant's proactive messages — a dedicated agent account, its API access token and your Chatwoot base URL.
# Chatwoot (Support Bot)
Backs the in-app support widget and the messages the assistant posts into conversations.
**Scope: Global defaults** only — a platform operator feature. A self-hosted deployment that does not run its own Chatwoot can leave it unconfigured; the widget stays off.
## 1. In Chatwoot
1. Create (or pick) a **dedicated agent account** for the integration and add it to the support inbox. An agent token inherits that agent's access, so do not use your own account, and give the agent only that inbox.
2. Sign in as that agent → **Profile settings** → copy the **Access token**.
3. Note your Chatwoot base URL: `https://app.chatwoot.com`, or your own host if self-hosted.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → Chatwoot (support bot)** → *Configure*:
| Field | Value |
| - | - |
| **Access token** | The agent's API access token |
| **Base URL** | e.g. `https://support.your-domain.com` |
**Save credentials.**
## 3. Verify
Open the support widget in the app and send a message; it should appear in the inbox as a conversation.
## Things that bite
* **Rotating the agent's token invalidates the old one immediately.**
* **Environment fallback**: `CHATWOOT_*` variables are used on a self-hosted host when no token is stored, so an empty row does not prove the widget is off.
## Related
* [Support chat](/support/support-chat)
# Microsoft Clarity
Source: https://docs.knoxcall.com/integrations/setup/clarity
Give the operator console read access to Microsoft Clarity insights — generate a data-export API token on the project and enter it, mindful of Clarity's ten-requests-a-day quota.
# Microsoft Clarity
Powers the operator console's Clarity insights card — sessions, engagement, dead and rage clicks on the marketing site.
**Scope: Global defaults** only — a platform operator feature.
## 1. In Clarity
1. [clarity.microsoft.com](https://clarity.microsoft.com) → open the project → **Settings → Data export → Generate new API token**. You must be an **Admin** on the project.
2. Name it for the console so it can be revoked independently, and copy it.
3. The project id is the segment in the project URL — useful context, not needed by the API.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → Microsoft Clarity** → *Configure*:
| Field | Value |
| - | - |
| **Data-export API token** | The token |
| **Clarity project ID** *(display only)* | From the project URL |
**Save credentials.**
## 3. Verify
The operator console's *Test connection* makes one minimal live-insights call. **It spends one of the day's requests.**
## Things that bite
* **Hard quota: roughly ten export requests per project per day.** Every test click and every uncached page load burns one, which is why the console caches aggressively.
* **Aggregated only.** Clarity returns insights over a one-to-three-day window; live visitor counts come from [GA4](/integrations/setup/google-analytics).
* **No environment fallback exists for this type.**
# DigitalOcean (Static-IP Egress)
Source: https://docs.knoxcall.com/integrations/setup/digitalocean
The DigitalOcean API token KnoxCall uses to create static-IP egress servers — custom token scopes, the project and control-plane tag fields, and why a tag beats an IP list.
# DigitalOcean (Static-IP Egress)
[Egress routing](/infrastructure/egress-routing) can give a route a fixed public address your upstream can allowlist. KnoxCall provisions the droplet, reserved IP and firewall for that on a DigitalOcean account — this card holds the token it does it with.
**Scope: Global defaults** only. On KnoxCall Cloud this is KnoxCall's account. A self-hosted deployment that wants static-IP egress uses its own DigitalOcean account here.
## 1. In DigitalOcean
1. **API → Tokens → Generate New Token**.
2. **Custom Scopes** — enough is: `droplet` create/read/delete · `reserved_ip` create/read/update · `firewall` create/read/update · `project` read/update. A legacy *Read + Write* token also works but grants the whole account.
3. Pick an **expiry** and diary it: an expired token fails at provisioning time, not at save time.
4. Copy the token (`dop_v1_…`) — shown once.
5. Optional but recommended: **tag the droplet that runs KnoxCall** (e.g. `knoxcall-control-plane`). The egress firewall admits port 8443 only from droplets carrying that tag — the tag *is* the allowlist, so do not reuse it for anything else.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → DigitalOcean** → *Configure*:
| Field | Value |
| - | - |
| **API token** | `dop_v1_…` |
| **Project ID** *(optional)* | Groups the created resources under one DigitalOcean project |
| **Control-plane droplet tag** | The tag from step 5. Preferred over IPs — it survives the control-plane address changing. |
| **Control-plane IPs (fallback)** | Comma-separated, only when KnoxCall does not run on a droplet in this account. If the address changes, every egress server silently stops accepting connections. |
**Save credentials.**
## 3. Verify
Provision one egress server from **Egress**, confirm it comes up with a reserved IP, route a test request through it, then delete it. A bad token fails on the firewall listing call first.
## Things that bite
* **Prefer the tag.** A stale IP list is the failure mode that looks like a healthy egress server refusing every connection.
* **No control-plane source, no egress server.** With a token but neither a tag nor an IP list, every static IP request is refused before anything is created — `409` with `reason: "misconfigured"` and a message naming the field to set. The **Request Static IP** dialog checks the same thing when it opens and disables the request until it is fixed.
* **Environment fallback.** With no stored token a self-hosted host falls back to `DO_API_TOKEN`, so a deleted row does not necessarily disable provisioning.
* **Costs are yours**: each egress server is a droplet plus a reserved IP on your account.
## Related
* [Egress routing](/infrastructure/egress-routing)
# Email
Source: https://docs.knoxcall.com/integrations/setup/email
Connect the email provider behind every KnoxCall email — Resend, SendGrid, Postmark, Mailgun, Amazon SES or any SMTP host — with the least-privilege credential for each, the fields to fill in, and how to prove mail is flowing.
# Email
One provider sends **every** platform email: verification and magic-link sign-in, password resets, invitations, alert notifications, operator notifications and — on the hosted service — the blog newsletter. A broken row here silences all of it, so this is the first integration a self-hosted deployment should configure.
**Scope: Global defaults.** The Email card only appears under **Global defaults**, because the mailer is platform-wide: on KnoxCall Cloud it is KnoxCall's provider, and on a self-hosted deployment it is yours. Workspaces do not configure an email provider — a workspace proves which addresses it may send *as* under **Settings → Sending domains**, and the platform provider does the sending.
The detail view has three tabs: **Credentials** (this guide), **Branding** (logo and colours on the templates) and **Templates** (the wording).
**Before you start**
* Admin access to a provider account.
* A sending domain you control, verified at the provider with **SPF and DKIM**. An unverified domain lands in spam or is refused outright; every provider below will show you the DNS records to publish.
## 1. Pick a provider and mint the credential
Pick one. Each section below gives the least-privilege credential and the KnoxCall fields it fills.
### Resend
The default provider on KnoxCall Cloud, and the only one with newsletter and engagement features.
1. [resend.com](https://resend.com) → **Domains → Add domain**, publish the DNS records, wait for *Verified*.
2. **API Keys → Create API key**. Permission **Sending access**, restricted to that domain. *Full access* is only needed if KnoxCall should also manage audiences (the newsletter features below).
3. Copy the key (`re_…`) — shown once.
| KnoxCall field | Value |
| - | - |
| **API key** | `re_…` |
| **From address** | An address on the verified domain, e.g. `noreply@your-domain.com` |
| **From name** | Shown as the sender, e.g. your product name |
| **Admin notification email** | The inbox that receives internal operator alerts (new signups, partner applications) |
| **Marketing webhook signing secret** *(optional)* | The signing secret of a Resend webhook pointed at `/webhooks/resend`, so unsubscribes and bounces flow back. Only for the newsletter. |
| **Marketing segment ID**, **Blog topic ID**, **Newsletter from** *(optional)* | Newsletter settings. The feature stays **off** until both IDs are set, and the topic's default subscription **must be opt-in** or you will broadcast to people who never subscribed. |
### SendGrid
1. SendGrid → **Settings → Sender Authentication** → authenticate your domain (DNS records).
2. **Settings → API Keys → Create API Key** → **Restricted Access** → grant **Mail Send** only.
3. Copy the key (`SG.…`) — shown once.
| KnoxCall field | Value |
| - | - |
| **API key** | `SG.…` |
| **From address** / **From name** | On the authenticated domain |
| **Admin notification email** | Operator inbox for internal alerts |
### Postmark
1. Postmark → **Sender Signatures / Domains** → add and verify the domain (DKIM + Return-Path).
2. Open the **Server** that should send → **API Tokens** → copy a **Server API token**. Not the *Account* token — that manages servers and cannot send.
| KnoxCall field | Value |
| - | - |
| **Server token** | The server API token |
| **From address** / **From name** | On the verified domain |
| **Message stream** | Usually `outbound` (the default transactional stream) |
| **Admin notification email** | Operator inbox for internal alerts |
### Mailgun
1. Mailgun → **Sending → Domains → Add New Domain** (e.g. `mg.your-domain.com`), publish the DNS records, wait for verification. Note the **region** the domain was created in.
2. **API Security → Add new key** (a sending key scoped to the domain is enough). Copy it — shown once.
| KnoxCall field | Value |
| - | - |
| **API key** | The sending key |
| **Mailgun domain** | `mg.your-domain.com` |
| **Region** | `us` (default) or `eu` — must match where the domain lives, or every send fails with a 401 |
| **From address** / **From name** | On the Mailgun domain |
| **Admin notification email** | Operator inbox for internal alerts |
### Amazon SES
1. SES console → **Verified identities → Create identity** → domain, with **Easy DKIM**. Publish the records and wait for *Verified*. If the account is still in the SES **sandbox**, request production access — the sandbox can only send to verified recipients.
2. IAM → **Users → Create user** (no console access) with an inline policy allowing only `ses:SendEmail` and `ses:SendRawEmail`. **Security credentials → Create access key → Application running outside AWS**; copy both halves.
| KnoxCall field | Value |
| - | - |
| **AWS access key ID** / **AWS secret access key** | The IAM user's key pair |
| **AWS region** | The region of the verified identity, e.g. `us-east-1` |
| **From address** | Must be on the verified identity |
| **From name** | Sender name |
| **Configuration set** *(optional)* | An SES configuration set for event tracking |
| **Admin notification email** | Operator inbox for internal alerts |
### Custom SMTP
Any host that accepts authenticated submission — Google Workspace, Microsoft 365, Fastmail, a relay of your own.
1. Create a **dedicated mailbox or app password** for KnoxCall that is allowed to send as your from-address. Do not reuse a person's password.
2. Note the host, port and TLS mode: port **587 with STARTTLS** is the usual answer; **465** for implicit TLS.
| KnoxCall field | Value |
| - | - |
| **Username** / **Password** | The submission account |
| **Host** | e.g. `smtp.example.com` |
| **Port** | `587` (STARTTLS) or `465` (implicit TLS) |
| **Use TLS (true/false)** | `true` for 465; `false` for 587, where TLS is negotiated with STARTTLS |
| **From address** / **From name** | An address the account may send as |
| **Admin notification email** | Operator inbox for internal alerts |
## 2. Enter it in KnoxCall
1. **Settings → Integrations → Global defaults → Email** → *Configure*.
2. Choose the **Provider** at the top. The fields below change to match the tables above.
3. Fill them in and **Save credentials**.
Switching provider later resets the credential fields — the old provider's secret is not carried over — but shared settings such as the from-address and the admin notification email are kept.
## 3. Verify
Send yourself something real: **Team → Invite** a spare address, or sign out and request a magic link. Check both the inbox and the spam folder. A self-hosted deployment also shows a setup warning on the dashboard until a working email provider exists; that warning is answered by the same resolution the mailer uses, so it cannot say "configured" while sends are failing.
## Things that bite
* **Required fields are enforced at send time, not save time.** Each provider needs its own set (Mailgun needs the domain, SES the region, SMTP the host); a row missing one saves cleanly and fails on the first real send. Send that test email.
* **The from-address must be on the verified domain.** Providers reject or silently drop anything else.
* **Resend engagement tracking rewrites links.** Open and click tracking are per-*domain* settings in Resend; when click tracking is on, every link in every message from that domain — password-reset and magic-link URLs included — is rewritten through Resend's redirect domain. If that is not acceptable, send credential mail from a second Resend domain with tracking off.
* **Corporate link scanners register as clicks.** A recorded click is not proof a human clicked.
* **Workspace sends.** Workflow email steps and alert notifications ride this provider too. A workspace may only send from a domain it has verified under **Settings → Sending domains**, or from a subdomain beneath one — never from a parent domain, and never from a KnoxCall-owned domain.
## Related
* [Alerts](/monitoring/alerts) — email as an alert destination
* [Workflows](/workflows/workflows-overview) — the email step
# Google Analytics (GA4)
Source: https://docs.knoxcall.com/integrations/setup/google-analytics
Give the operator console read access to your GA4 property — enable the Data API, create a service account with a JSON key, grant it Viewer on the property, and enter the key and the numeric property ID.
# Google Analytics (GA4)
Powers the operator console's web-analytics page — live visitors, sessions, top pages and referrers — pulled from the GA4 property that tracks your marketing site.
**Scope: Global defaults** only — a platform operator feature. Leave it unconfigured if you do not run the console.
## 1. In Google Cloud and GA4
1. **Google Cloud Console → APIs & Services → Library** → enable **Google Analytics Data API**.
2. **IAM & Admin → Service Accounts → Create service account**. Grant it **no** project roles at the project step — none are used.
3. Open the service account → **Keys → Add key → Create new key → JSON**. The file downloads once.
4. **GA4 → Admin → Property Access Management → Add users** → the service account's email (`…@….iam.gserviceaccount.com`) with the **Viewer** role. That is the only grant it needs.
5. **GA4 → Admin → Property Settings** → copy the **numeric Property ID** (not the `G-XXXXXXX` measurement ID).
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → Google Analytics (GA4)** → *Configure*:
| Field | Value |
| - | - |
| **Service account JSON key** | The entire contents of the JSON file, pasted verbatim — including the newlines inside `private_key` |
| **GA4 property ID (numeric)** | e.g. `123456789` |
**Save credentials.**
## 3. Verify
The operator console's *Test connection* performs a token exchange and a minimal one-day report against the property, and reports a bad property id distinctly from a bad key. The web-analytics page then populates.
## Things that bite
* **Numeric property ID, not the measurement ID.**
* **Paste the whole file.** A JSON key with mangled newlines fails as an authentication error, not a parse error.
* **No environment fallback exists for this type**; an unset row simply means the page has no data.
* **Viewer at the account level works too**, but is broader than needed.
# Google Places
Source: https://docs.knoxcall.com/integrations/setup/google-places
The Google Maps Platform key behind address autocomplete on KnoxCall's signup and address forms — enable the Places API, restrict the key to your hostnames, and understand that this key is served to browsers by design.
# Google Places
Address autocomplete on the signup and billing-address forms. **This key is delivered to every browser that loads those forms** — that is how the Places JavaScript library works — so it is public by design, and the restrictions in step 1 are the only thing between it and someone else's billing.
**Scope: Global defaults** only.
## 1. In Google Cloud
1. **APIs & Services → Library** → enable the **Places API** (and **Maps JavaScript API** if your forms load the JS widget).
2. **Credentials → Create credentials → API key**.
3. **Edit the key immediately**:
* **Application restrictions → Websites** → add every hostname that renders the forms, including staging.
* **API restrictions → Restrict key** → select only the Places API (and Maps JavaScript API if enabled).
4. Set a **budget alert** on the project.
5. Copy the key (`AIza…`).
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → Google Places** → *Configure*:
| Field | Value |
| - | - |
| **API key** | `AIza…` |
**Save credentials.** The key is stored encrypted like every other secret, and served to the forms at load time.
## 3. Verify
Open the signup address field and type three characters — suggestions mean the key is live and the referrer is allowed. No suggestions with no error usually means a referrer restriction that does not include this host.
## Things that bite
* **An unrestricted key here is a billing-theft vulnerability, not a theoretical one.** Restrict it before saving it.
* **Every host that renders the form must be in the referrer list**, or autocomplete silently returns nothing there.
* **Environment fallback**: `GOOGLE_PLACES_API_KEY` is used on a self-hosted host when no row is stored.
# Migration Signing Key
Source: https://docs.knoxcall.com/integrations/setup/migration-signing-key
The RSA private key KnoxCall signs Workload Identity Federation assertions with during cross-cloud secret-store migrations — generate it, base64-encode the PEM, store it, and restart the API workers.
# Migration Signing Key
During a [secret-store migration](/essentials/migrations/overview) from Azure Key Vault or Google Secret Manager, KnoxCall presents signed assertions to the customer's cloud for **Workload Identity Federation**, and publishes the public half at its JWKS endpoint. This card holds the private key.
**Scope: Global defaults** only. There is no provider — you generate the key yourself. It is a signing identity: anyone holding it can mint assertions that federate as your KnoxCall deployment.
## 1. Generate the key
```bash theme={"dark"}
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out migration-signing.pem
base64 -w0 migration-signing.pem # macOS: base64 -i migration-signing.pem | tr -d '\n'
```
The second command prints the PKCS#8 PEM as a single base64 line.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → Migration signing key** → *Configure*:
| Field | Value |
| - | - |
| **Signing key (base64 PEM)** | The single-line base64 output |
**Save credentials**, then delete the local PEM file — the encrypted store is now the copy of record.
## 3. Restart, then verify
The key is **read once at startup**. Restart the API workers, then fetch the JWKS endpoint: it serves the derived public key once a valid key has loaded.
## Things that bite
* **Saving a new key does not take effect until the API workers restart.**
* **A present-but-unreadable row is logged loudly and then ignored**, falling back to `MIGRATION_SIGNING_KEY` from the environment if set. Check the logs rather than assuming the new key took.
* **Rotating invalidates any assertion a relying party has pinned** — coordinate with the migration counterparty before you do.
## Related
* [Secret store migrations](/essentials/migrations/overview)
# GitHub Sign-In
Source: https://docs.knoxcall.com/integrations/setup/oauth-github
Create a GitHub OAuth App for KnoxCall sign-in — organization-owned, one authorization callback URL, a generated client secret, and the client ID and secret to paste into Settings → Integrations.
# GitHub Sign-In
Adds a **Continue with GitHub** button to the login page, backed by an OAuth App you own. Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under.
**Before you start**
* Owner access to the GitHub **organization** that should own the app. You *can* create the app under a personal account, but an app owned by a person dies with that person's account.
* The **Authorized redirect URI** from the KnoxCall card — **Settings → Integrations → OAuth Sign-In Methods → GitHub**. Self-hosted: set `GITHUB_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment).
## 1. Create the OAuth App
Use an **OAuth App**, not a GitHub App: sign-in is the OAuth web flow, and a GitHub App adds installation and permission machinery KnoxCall does not use.
1. Organization → **Settings → Developer settings → OAuth Apps → New OAuth App**. (For a personal account: your profile → **Settings → Developer settings**.)
2. **Application name**: for example `KnoxCall`. **Homepage URL**: your KnoxCall URL.
3. **Authorization callback URL**: the value from the KnoxCall card:
* KnoxCall Cloud: `https://auth.knoxcall.com/callback`
* Self-hosted: `https:///auth/github/callback` (the value of `GITHUB_OAUTH_REDIRECT_URI`)
4. Leave **Enable Device Flow** off. **Register application**.
5. On the app page copy the **Client ID**, then **Generate a new client secret** and copy it — GitHub shows it once.
## 2. Enter it in KnoxCall
**Settings → Integrations** → scope → **OAuth Sign-In Methods** → **GitHub** tab:
| Field | Value |
| - | - |
| **Authorized redirect URI** | Read-only. The value you registered in step 1. |
| **Client ID** | The app's Client ID. |
| **Client secret** | The generated secret. Leave blank on a later edit to keep the stored one. |
**Save.**
## 3. Verify
Open the login page in a private window and click **Continue with GitHub**. GitHub asks the person to authorise the app for `read:user` and `user:email`; a first-time account is linked to an existing KnoxCall account with the same verified email, or created.
## Things that bite
* **Exactly one callback URL per OAuth App.** Unlike Google, a GitHub OAuth App cannot hold both a staging and a production callback. Create one app per KnoxCall environment.
* **Verified email required.** KnoxCall reads the person's email through `user:email` and only accepts a **verified** address. A GitHub account whose addresses are all unverified is refused with `error=unverified_email`; a private email is fine — the scope reads it even when it is hidden from the public profile.
* **Organization-owned apps and third-party access restrictions.** If the organization restricts third-party OAuth access, an app owned by *that* organization is exempt; an app owned by a personal account may be blocked from reading membership, though sign-in itself does not need it.
* **Rotating the secret.** Generating a new client secret does not revoke the old one until you delete it, so you can generate, save in KnoxCall, then delete the old secret with no gap.
* **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `GITHUB_OAUTH_CLIENT_ID` / `_SECRET` falls back to those.
## Related
* [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting
* [Google](/integrations/setup/oauth-google) · [Microsoft Entra ID](/integrations/setup/oauth-microsoft)
# Google Sign-In
Source: https://docs.knoxcall.com/integrations/setup/oauth-google
Create a Google OAuth client for KnoxCall — consent screen, OAuth 2.0 Client ID of type Web application, the authorized redirect URI, and the client ID and secret to paste into Settings → Integrations.
# Google Sign-In
Adds a **Continue with Google** button to the login page, backed by an OAuth client you own. Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first if you have not — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under.
**Before you start**
* A Google Cloud project you can create credentials in (the *Owner* or *Editor* role, or *OAuth Config Editor*).
* The **Authorized redirect URI** from the KnoxCall card — open **Settings → Integrations → OAuth Sign-In Methods → Google** and copy it. Self-hosted: set `GOOGLE_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment), or the card shows the Cloud value.
## 1. Configure the consent screen
You cannot create an OAuth client until the project has a consent screen.
1. Google Cloud Console → **APIs & Services → OAuth consent screen** (labelled *Google Auth Platform* in newer consoles).
2. **User type**:
* **Internal** — only accounts in your Google Workspace organisation can sign in. Right for a company deployment; no verification needed.
* **External** — any Google account. Starts in *Testing* mode, which only admits the test users you list; leaving Testing requires Google's app verification.
3. App name, support email and developer contact. Nothing else is required — KnoxCall only requests `openid`, `email` and `profile`, which are non-sensitive scopes.
## 2. Create the OAuth client
1. **APIs & Services → Credentials → Create credentials → OAuth client ID**.
2. **Application type: Web application**. Name it (for example `KnoxCall`).
3. Under **Authorized redirect URIs → Add URI**, paste the value from the KnoxCall card:
* KnoxCall Cloud: `https://auth.knoxcall.com/callback`
* Self-hosted: `https:///auth/google/callback` (the value of `GOOGLE_OAUTH_REDIRECT_URI`)
*Authorized JavaScript origins* are not needed — the flow is server-side.
4. **Create**, then copy the **Client ID** (ends in `.apps.googleusercontent.com`) and the **Client secret**. Google shows the secret at creation and lets you download it later; treat the download like any other credential.
No Google API needs enabling: sign-in uses the OpenID Connect userinfo endpoint, which the consent screen already covers.
## 3. Enter it in KnoxCall
**Settings → Integrations** → scope → **OAuth Sign-In Methods** → **Google** tab:
| Field | Value |
| - | - |
| **Authorized redirect URI** | Read-only. The value you registered in step 2. |
| **Client ID** | The `….apps.googleusercontent.com` id. |
| **Client secret** | The secret. Leave blank on a later edit to keep the stored one. |
**Save.** The tab's dot turns green and the login page shows the button.
## 4. Verify
Open the login page in a private window and click **Continue with Google**. A successful sign-in lands on the dashboard; a returning account is signed straight in, and a first-time account is linked to an existing KnoxCall account with the same verified email, or created.
If Google shows **Error 400: redirect\_uri\_mismatch**, the URI in step 2 is not identical to the one in the card. The error page names the URI KnoxCall sent — register exactly that.
## Things that bite
* **External + Testing mode.** Only listed test users can sign in (up to 100), and the consent screen shows an "unverified app" warning. Fine for a pilot; for everyone else, either switch to Internal (Workspace organisations) or complete verification.
* **Internal is per organisation.** An Internal consent screen refuses accounts outside your Workspace domain, including personal Gmail accounts. That is a feature, not a bug — but it is why a contractor's login "does nothing".
* **The secret is not stored on the Google side in a retrievable way once rotated.** If you *Reset secret* in Google, update KnoxCall immediately; the old secret stops working the moment the new one is created.
* **One client can hold several redirect URIs.** A staging and a production KnoxCall can share one Google client — unlike GitHub, which allows exactly one callback URL per app.
* **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` falls back to those, so the button does not disappear.
## Related
* [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting
* [Microsoft Entra ID](/integrations/setup/oauth-microsoft) · [GitHub](/integrations/setup/oauth-github)
* [SAML with Google Workspace](/sso/google-workspace) — federated SSO instead of social sign-in
# Microsoft Sign-In (Entra ID)
Source: https://docs.knoxcall.com/integrations/setup/oauth-microsoft
Create a Microsoft Entra ID app registration for KnoxCall sign-in — supported account types, the Web redirect URI, a client secret (Value, not Secret ID), User.Read, and the allowed-directory list that stops other Entra tenants signing in.
# Microsoft Sign-In (Entra ID)
Adds a **Continue with Microsoft** button to the login page, backed by an app registration you own in Microsoft Entra ID (formerly Azure AD). Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under.
This is the OpenID Connect / OAuth object (an **App registration**). Enterprise SAML federation with Entra — claims, group mapping, token encryption, Single Logout — is a different object and a different guide: [SAML SSO with Microsoft Entra ID](/sso/entra-id).
**Before you start**
* Permission to create app registrations in your directory (*Application Developer* or better; *Application Administrator* if your directory restricts registration).
* The **Authorized redirect URI** from the KnoxCall card — **Settings → Integrations → OAuth Sign-In Methods → Microsoft**. Self-hosted: set `MICROSOFT_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment).
* Your **Directory (tenant) ID** — you will want it for step 4.
## 1. Register the application
1. [Entra admin center](https://entra.microsoft.com) → **Identity → Applications → App registrations → New registration**.
2. **Name**: for example `KnoxCall`.
3. **Supported account types**:
* **Accounts in this organizational directory only (single tenant)** — the safe default for a company deployment. Microsoft itself then refuses sign-ins from any other directory.
* **Accounts in any organizational directory (multitenant)** — only if people from *other* organisations must sign in. If you pick this, the allowed-directory list in step 4 becomes essential.
4. **Redirect URI**: platform **Web**, value from the KnoxCall card:
* KnoxCall Cloud: `https://auth.knoxcall.com/callback`
* Self-hosted: `https:///auth/microsoft/callback` (the value of `MICROSOFT_OAUTH_REDIRECT_URI`)
5. **Register**. On the **Overview** page copy the **Application (client) ID** and the **Directory (tenant) ID**.
## 2. Create a client secret
1. The app → **Certificates & secrets → Client secrets → New client secret**.
2. Description and expiry. Entra allows at most 24 months; pick the longest your policy allows and **diary the expiry** — sign-in stops the day it lapses, with no warning from KnoxCall.
3. **Add**, then copy the **Value** column immediately. It is masked as soon as you leave the blade.
Copy the **Value**, not the **Secret ID**. The Secret ID is a GUID that identifies the secret; it is not the secret. Pasting it into KnoxCall fails at sign-in with an *invalid client secret* error (`AADSTS7000215`), not at save time.
## 3. Check API permissions
**API permissions** should show **Microsoft Graph → User.Read (Delegated)** — it is added by default on a new registration. That is the only permission KnoxCall uses. No application permissions, and no admin consent is needed beyond `User.Read` (users consent on first sign-in; you may grant admin consent for the directory to skip that prompt).
## 4. Enter it in KnoxCall
**Settings → Integrations** → scope → **OAuth Sign-In Methods** → **Microsoft** tab:
| Field | Value |
| - | - |
| **Authorized redirect URI** | Read-only. The value you registered in step 1. |
| **Client ID** | The *Application (client) ID*. |
| **Client secret** | The secret **Value** from step 2. Leave blank on a later edit to keep the stored one. |
| **Allowed directory (tenant) IDs** | Your *Directory (tenant) ID* — see below. Comma-separated if more than one. |
**Save.**
### Restrict sign-in to your directory
With the allowed-directory list **empty**, KnoxCall uses Microsoft's `/common` endpoint, which accepts a sign-in from **any** Entra directory — including one anybody can create for free in minutes. The account still has to present a verified email, but "any directory" is rarely what you mean.
Put your directory ID in the box. With exactly one ID, KnoxCall addresses that directory's endpoints directly, so Microsoft refuses every other directory before KnoxCall is even involved; with several, KnoxCall checks the directory claim on every sign-in and refuses the rest. Leave it empty only if you deliberately chose *multitenant* in step 1 and want that behaviour.
On a self-hosted deployment `MICROSOFT_OAUTH_ALLOWED_TENANT_IDS` is the environment form of the same list.
## 5. Verify
Open the login page in a private window and click **Continue with Microsoft**. Sign in with an account in your directory; a first-time account is linked to an existing KnoxCall account with the same verified email, or created.
## Things that bite
* **`AADSTS50011`** — the redirect URI registered on the app is not the one KnoxCall sent. Compare byte for byte, including the platform: it must be **Web**, not *SPA* or *Mobile*.
* **`AADSTS7000215`** — invalid client secret. Almost always the Secret ID was pasted instead of the Value, or the secret has expired.
* **`AADSTS50020` / "user account does not exist in tenant"** — the person's account is in a directory the app does not admit (single-tenant registration, or the allowed-directory list). That is the control working.
* **Secrets expire.** Rotate before the date: create a second secret, save it in KnoxCall, then delete the old one. There is no overlap problem — KnoxCall holds one secret and uses it from the moment you save.
* **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `MICROSOFT_OAUTH_CLIENT_ID` / `_SECRET` falls back to those.
## Related
* [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting
* [Google](/integrations/setup/oauth-google) · [GitHub](/integrations/setup/oauth-github)
* [SAML SSO with Microsoft Entra ID](/sso/entra-id) — federated SSO instead of social sign-in
# OAuth Sign-In Methods
Source: https://docs.knoxcall.com/integrations/setup/oauth-sign-in
Let people sign in to KnoxCall with Google, Microsoft or GitHub using your own OAuth apps — the one redirect URI all three share, the scopes requested, which scope to save under, and the environment variables a self-hosted deployment uses.
# OAuth Sign-In Methods
The **OAuth Sign-In Methods** card on **Settings → Integrations** holds up to three OAuth apps — one per provider — and the login page shows a *Continue with …* button for each one that is configured and enabled. This page covers what is common to all three; each provider has its own walkthrough:
[Google](/integrations/setup/oauth-google) · [Microsoft Entra ID](/integrations/setup/oauth-microsoft) · [GitHub](/integrations/setup/oauth-github)
This is **social / OAuth sign-in**, where the provider proves an email address and KnoxCall matches it to an account. For federated enterprise SSO — SAML 2.0 with your identity provider, domain verification and Single Logout — see [Single Sign-On (SAML)](/sso/overview).
## Do you need your own apps?
* **KnoxCall Cloud** already has Google, Microsoft and GitHub wired to KnoxCall's own OAuth apps. You do not need to configure anything for the buttons to work.
* **Self-hosted deployments** must bring their own apps: KnoxCall's production apps only accept redirect URIs on `knoxcall.com`, so a deployment on `knox.example.com` cannot reuse them.
Which scope to save under:
| Deployment | Save under | Why |
| - | - | - |
| Self-hosted, one workspace | **This tenant** or **Global defaults** | With exactly one workspace holding a provider's app, KnoxCall uses it for every sign-in on that provider regardless of host. Global defaults works too and is the clearer choice when the operator is the admin. |
| Self-hosted, several workspaces | **Global defaults** | One platform-wide app per provider. |
| KnoxCall Cloud | — | Sign-in already works with KnoxCall's apps. A workspace-scoped app is not supported at sign-in on the hosted service today: the provider redirects to the shared callback host, which is not a workspace host, so a per-workspace app cannot be selected there. |
## The redirect URI
Every provider asks for an *authorized redirect URI* (Google), *redirect URI* (Microsoft) or *authorization callback URL* (GitHub). The value must match what KnoxCall sends **byte for byte** — a trailing slash, a different scheme or a different path is a mismatch.
The card shows the exact value for your deployment in the **Authorized redirect URI** box. It is the server's answer, not something derived from the page you are looking at:
| Deployment | Redirect URI |
| - | - |
| KnoxCall Cloud | `https://auth.knoxcall.com/callback` — one URI shared by all three providers; KnoxCall tells them apart from the sign-in state. |
| Self-hosted | Whatever you set in the server environment, per provider (below). Until you set it, the card shows the Cloud value, which will not work for your domain. |
### Self-hosted: set the redirect URI in the server environment
On a self-hosted deployment the shared `/callback` path is not routed, so point each provider straight at its handler on your own host and register the same value with the provider:
```bash theme={"dark"}
GOOGLE_OAUTH_REDIRECT_URI=https://knox.example.com/auth/google/callback
MICROSOFT_OAUTH_REDIRECT_URI=https://knox.example.com/auth/microsoft/callback
GITHUB_OAUTH_REDIRECT_URI=https://knox.example.com/auth/github/callback
```
Restart the API after changing them. The card reads the live values, so it shows what you set. If a provider reports a mismatch, compare the two strings character by character — that is the entire diagnosis in almost every case.
## What KnoxCall asks the provider for
KnoxCall requests the minimum needed to identify the person and read a verified email address:
| Provider | Scopes |
| - | - |
| Google | `openid`, `email`, `profile` |
| Microsoft | Microsoft Graph `User.Read` (delegated) |
| GitHub | `read:user`, `user:email` |
Sign-in is **pure sign-in**: the token from the provider is spent on one profile lookup and not stored. There is no offline access and no refresh token, and users are not asked to re-consent on every login. (Token injection for *your* upstream APIs is a different feature — see [OAuth 2.0 secrets](/security/oauth2-flow).)
## How accounts are matched
1. A returning user (same provider, same provider user id) signs straight in.
2. Otherwise KnoxCall looks for an existing account with the **same email address** and links the provider to it — but only if the provider reports the address as **verified**. An unverified address is refused (`error=unverified_email`), because linking on an unverified address would let someone attach their provider identity to your account.
3. Otherwise a new account is created from the verified email.
Email matching is case-insensitive. A person who signed up with a password can therefore add "Continue with Google" simply by signing in with the same address.
## Configuring a provider
1. **Settings → Integrations** → pick the scope → **OAuth Sign-In Methods** → *Configure* (or *Edit*).
2. Choose the provider tab. A dot on each tab shows whether it is already configured.
3. Copy the **Authorized redirect URI** into the provider's app (see the provider guide for exactly where).
4. Paste the provider's **Client ID** and **Client secret** and **Save**. Microsoft has one more field, the allowed directory IDs — read [that guide](/integrations/setup/oauth-microsoft#restrict-sign-in-to-your-directory) before leaving it blank.
5. Open the login page in a private window and use the new button.
*Disconnect* on a provider tab removes the app; people who only ever signed in with that provider will need another sign-in method (a magic link or a passkey still works for them).
## Environment variables (self-hosted)
The client credentials can also come from the environment. A stored row wins; the variables are the fallback when no row exists.
| Provider | Client ID | Client secret | Redirect URI |
| - | - | - | - |
| Google | `GOOGLE_OAUTH_CLIENT_ID` | `GOOGLE_OAUTH_CLIENT_SECRET` | `GOOGLE_OAUTH_REDIRECT_URI` |
| Microsoft | `MICROSOFT_OAUTH_CLIENT_ID` | `MICROSOFT_OAUTH_CLIENT_SECRET` | `MICROSOFT_OAUTH_REDIRECT_URI` |
| GitHub | `GITHUB_OAUTH_CLIENT_ID` | `GITHUB_OAUTH_CLIENT_SECRET` | `GITHUB_OAUTH_REDIRECT_URI` |
`MICROSOFT_OAUTH_ALLOWED_TENANT_IDS` is the environment form of Microsoft's allowed-directory list.
## Troubleshooting
| What you see | What it means |
| - | - |
| The login page has no button for the provider | No configured, enabled app was found for it. Check the scope you saved under, and that the row is enabled. |
| `/login?error=oauth_not_configured&provider=…` | Same as above, reached by URL. |
| `redirect_uri_mismatch` (Google), `AADSTS50011` (Microsoft), "The redirect\_uri MUST match…" (GitHub) | The URI registered on the provider differs from the one in the card. Compare them byte for byte. |
| `/login?error=unverified_email` | The provider reported an unverified address. Verify it at the provider and try again. |
| `/login?error=oauth_failed` | The code-for-token exchange failed — usually a wrong client secret (Microsoft: the *Secret ID* was pasted instead of the *Value*) or a secret that has expired. |
## Related
* [Single Sign-On (SAML)](/sso/overview) — enterprise federation with domain verification
* [Team collaboration](/account/team-collaboration) — inviting people and roles
# OpenAI
Source: https://docs.knoxcall.com/integrations/setup/openai
Configure OpenAI as KnoxCall's fallback AI provider for anomaly and spec analysis — a project key with restricted permissions, and where to paste it.
# OpenAI
The fallback provider for anomaly analysis and OpenAPI spec analysis when Anthropic is not configured, and one of the answer engines the platform's AI-visibility probes query.
**Scope: Global defaults** only. Workspace AI features use the workspace's [Anthropic](/integrations/setup/anthropic) key; this card is for the platform operator (KnoxCall on Cloud, you on a self-hosted deployment).
## 1. In the OpenAI Platform
1. [platform.openai.com](https://platform.openai.com) → select or create the **project** that should own the spend.
2. **API keys → Create new secret key → Project key**, scoped to that project.
3. **Permissions: Restricted**, granting **Model capabilities: Write** (chat completions) and **Models: Read** (the health probe lists models).
4. Copy the key (`sk-proj-…`) — shown once.
## 2. Enter it in KnoxCall
**Settings → Integrations → Global defaults → OpenAI** → *Configure*:
| Field | Value |
| - | - |
| **API key** | `sk-proj-…` |
**Save credentials.**
## 3. Verify
With no Anthropic key configured, run AI analysis on an alert; it falls through to OpenAI. The operator console's *Test connection* lists models with the stored key — a key without *Models: Read* fails that test while still working for completions.
## Things that bite
* **A stored key wins over `OPENAI_API_KEY`** on a self-hosted host; the variable is only the fallback when no row exists.
* **Project keys, not legacy user keys.** A user key carries the person's full access and dies with their account.
## Related
* [Anthropic](/integrations/setup/anthropic) — the primary provider
# OpenTelemetry Export (OTLP)
Source: https://docs.knoxcall.com/integrations/setup/opentelemetry
Ship the whole gateway's traces, logs and metrics to your OTLP collector — the endpoint and header format KnoxCall expects, then copy-paste recipes for Grafana Cloud, Honeycomb, New Relic, Datadog, SigNoz, Elastic, Axiom, Sentry and a self-run OpenTelemetry Collector.
# OpenTelemetry Export (OTLP)
The **OpenTelemetry export** card ships the **whole gateway's** telemetry — one `SERVER` span per request, one log record per proxied request and webhook delivery, and request count / latency metrics every 60 seconds — to a single OTLP/HTTP endpoint. What each signal contains is documented in [OpenTelemetry export](/monitoring/opentelemetry); this page is the setup.
**Scope: Global defaults** only. This is the deployment operator's view of KnoxCall itself: on KnoxCall Cloud it is KnoxCall's; on a self-hosted deployment it is yours. A workspace that wants *its own* request logs and metrics in *its own* collector uses [Telemetry export](/integrations/setup/telemetry-export) instead.
## The three fields
| Field | What to enter |
| - | - |
| **OTLP HTTP endpoint** | The collector's OTLP/HTTP **base** URL. KnoxCall appends `/v1/traces`, `/v1/logs` and `/v1/metrics` itself — a value ending in `/v1/traces` produces `/v1/traces/v1/traces`. |
| **OTLP auth headers** | Comma-separated `name=value` pairs, e.g. `Authorization=Bearer , X-Scope-OrgID=acme`. Stored encrypted because it carries the credential. |
| **Service name** | The resource `service.name`; default `knoxcall`. |
Plus three toggles — **Export traces**, **Export integration logs**, **Export metrics** — all on by default. Turn a signal off rather than pointing it at a backend that rejects it (see Sentry below).
## 1. Configure
1. **Settings → Integrations → Global defaults → OpenTelemetry export** → *Configure*.
2. Endpoint, headers, service name from the recipe for your backend below.
3. **Save.** Export starts immediately in the API server, across all its workers — no restart.
## 2. Verify
Spans and log records should appear in your backend within a minute of the next proxied request; metrics on the next 60-second tick. Nothing is logged locally on success, so the backend is the check.
## Recipes
Endpoints and header names are those published by each vendor at the time of writing; confirm on the vendor's OTLP settings page, which is also where you mint the credential. Mint a **write-only ingest** credential in every case — never an admin API key.
### Grafana Cloud
1. Grafana Cloud portal → your stack → **Connections → OpenTelemetry** (or **Configure** on the OTLP tile). It shows the endpoint, your **instance id** and a **token** generation button.
2. Create a token with the `metrics:write`, `logs:write` and `traces:write` scopes.
3. Grafana authenticates with HTTP Basic: `base64(":")`.
| Field | Value |
| - | - |
| Endpoint | `https://otlp-gateway-.grafana.net/otlp` |
| Headers | `Authorization=Basic ` |
### Honeycomb
1. **Environment settings → API keys → Create ingest key** (Send events only).
2. Metrics land in a dataset named by a header; traces and logs use the service name.
| Field | Value |
| - | - |
| Endpoint | `https://api.honeycomb.io` (EU: `https://api.eu1.honeycomb.io`) |
| Headers | `x-honeycomb-team=, x-honeycomb-dataset=knoxcall-metrics` |
### New Relic
Use an **ingest license key**, not a user key.
| Field | Value |
| - | - |
| Endpoint | `https://otlp.nr-data.net` (EU: `https://otlp.eu01.nr-data.net`) |
| Headers | `api-key=` |
### Datadog
Datadog ingests OTLP through the **Datadog Agent** or an OpenTelemetry Collector with the Datadog exporter, not a public OTLP URL. Enable the Agent's OTLP/HTTP receiver (`otlp_config.receiver.protocols.http.endpoint: 0.0.0.0:4318`) and point KnoxCall at it — over a private network or TLS, since the Agent has no auth of its own.
| Field | Value |
| - | - |
| Endpoint | `https://:4318` |
| Headers | *(none)* — put the receiver on a private network or behind a TLS proxy |
### SigNoz
| Field | SigNoz Cloud | Self-hosted SigNoz |
| - | - | - |
| Endpoint | `https://ingest..signoz.cloud:443` | `https://:4318` |
| Headers | `signoz-ingestion-key=` | *(none by default)* |
### Elastic
Elastic Cloud and Elastic APM Server accept OTLP/HTTP natively.
| Field | Value |
| - | - |
| Endpoint | The APM Server / managed OTLP endpoint of your deployment (`https://.apm..cloud.es.io`) |
| Headers | `Authorization=ApiKey ` (or `Authorization=Bearer