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.
Class B reveals — vault tokens, transit decrypt, database credentials, issued certificates, server-mode agents, readable shares, migration review, captured payloads and webhook signing secrets — are Owner and Admin only, and each takes a fresh second factor and an audit entry
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/agentswith"mode": "server"), rotating a server-mode agent’s secret, creating an install token, and switching an agent to proxy mode (switch_to_proxy, orswitch_modetoproxy); - 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).
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. A later change puts
those reads behind the same Owner and Admin rule, with its own entry here.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.Only a workspace's Owner or an Admin can change its company billing address or tax ID from the Account page
A change to existing behavior for Editors and Viewers. The Management API (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:
/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:- An enrolled second factor. Without one, the request answers
403with"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
403with “This session is scoped to a different tenant. Sign in again to switch tenants.”
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.Every reveal of a secret value, a certificate, a shared secret, a credentialed workflow step test or a webhook trigger URL now asks for a fresh second factor and is recorded in the audit log
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 — apfx,p12orkeycertificate 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.
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.Adding a passkey to an account that already has a factor asks for the same verification as adding an app or a security key
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.A security key can now complete step-up verification, and adding an authenticator app or a security key to an account that already has a factor asks for that factor first
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.Your account-wide proxy rate limit now allows bursts of ten seconds of your plan's rate, not a whole minute
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: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-Limiton a tenant429, thelimitin that429’s body andlimit=inX-Knox-RateLimit-Tenantstill report your per-minute rate — the same numbers as before.- New:
X-RateLimit-Tenant-Burst,X-RateLimit-Burston a tenant429,burstin its body, andburst=appended at the end ofX-Knox-RateLimit-Tenantstate 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=andRetry-Afterstill mean “when the bucket is full again”, which is now at most about ten seconds away instead of a minute — so a client that waitsRetry-Afterafter a tenant429waits seconds, not up to a minute.
Headers added by KnoxCall's own edge are no longer forwarded to your upstream
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.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.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.Audit-log entries made through the CLI or a signed-in app now name the person
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": "<uuid>" }) 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.Audit-log details: sanitised everywhere, withheld from Viewers and Editors, and a new details_redacted flag
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 token issued to a person no longer reaches owner/admin or reveal-class operations on /v1
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 createandknoxcall ai mintneed an owner or admin).
/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.A scope-narrowed OAuth token can no longer create a credential wider than itself
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-keysminted a key with no roles, which on the proxy can invoke every route in its mode. A narrowed token now gets403 privilege_escalationand mints no key at all, with or withoutrole_ids.POST /v1/agentsreturned an agent credential, which the proxy also accepts for every route. A narrowed token now gets403 privilege_escalation.POST /v1/oauth-clientsandPATCH /v1/oauth-clients/{id}accepted anyallowed_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, andclient_credentialsas 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-secretfrom a narrowed token is refused for a client wider than the token, since rotation hands back that client’s secret.
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.A per-route raw-card egress grant, and a new 400 if you send it to /v1
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.Test mode has card numbers that decline, require 3-D Secure, or arrive already expired
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:
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.detokenizeaudit row, counts against your detokenize meter once, and appears in the vault’s Destinations view attributed to the host you named.
Card tokens carry two BIN fields — funding type and issuing country. They are null on every token today, and we say why
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.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.Card tokens can carry the card's expiry date, and a new vault.token.expiring webhook fires 60 and 30 days before it
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: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.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.The clear-card-number refusal now covers workflow email, SMS and database steps, inbound webhook forwarding, and the AI gateway's MCP and SDK paths
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: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.Responses to a request that detokenized a pan vault token are now re-tokenized; two new refusals, pan_streaming_unsupported and response_not_retokenizable
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
and Vaults — a processor that echoes the card number back.A clear payment card number in an outbound body is refused with 403 raw_pan_egress_refused unless the destination host is on your raw-card allowlist, which starts empty
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.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.- Tokenize the number and send the token. Create a vault with
"token_format": "pan"and anallowed_destinationslist, tokenize the card into it, and reference the token in your request as{{ token: <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. - 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 withGET /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’sallowed_destinations. Changes are audited asegress.pan_allowed_hosts_updatedwith the previous list, so any change can be reverted from the audit trail alone.
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.Tokenizing a card number into a vault that is not pan now returns 400 wrong_format
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.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.Creating a detokenize or decrypt route action now requires vaults:detokenize / transit:decrypt on the token's grant
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.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 narrowings.Polling app triggers no longer skip a burst larger than one poll: the remainder now fires on the next poll
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:
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.
Calling the ephemeral proxy now requires proxy:invoke, on every HTTP method
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: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.An authorization or device request that names no scope now mints the client's allowed_scopes, not an unnarrowed token
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 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”.Two GET endpoints that return decrypted material now require their own scope
Breaking for narrowed OAuth tokens. Two Universal grants (
/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: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:secrets:*, vaults:*, *:*, *) continue to reach both endpoints.Unified error envelope, request correlation, cross-worker rate-limit headers, dual idempotency-key header, typed secret creation in SDKs
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.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 429s —
plus Retry-After on a 429. See the new Rate limits reference.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.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 and
create certificate secret endpoints.