Everything a developer needs, on one page.
Keys, the gateway, the CLI, the SDKs, the receipt format, and what each of them proves.
In one paragraph. Lyntway watches AI traffic, records what was in it as classes and counts, and signs that record. The receipt verifies against a published key with no account and no network, so your evidence does not depend on us continuing to exist. It does not store your content unless you switch that on.
Connect it
1. Change two lines in your environment
The official SDKs read the base URL and the key from the environment, so setting these two governs every call the application makes— including code you did not write and libraries you do not control. No code changes, no new files. Restart and it is live.
OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 OPENAI_API_KEY=$LYNTWAY_KEY~$YOUR_OPENAI_KEY
The two keys are joined by a tilde because OpenAI's SDK gives you one key field, sent as a bearer token, and no way to add a header. We split them here, forward yours, and keep neither. The tilde pairing is for OpenAI only. Anthropic's SDK sends its key in its own header, which we do not authenticate against, so a pair placed there is refused. That SDK has a second variable for a bearer token, and that is where ours goes:
ANTHROPIC_BASE_URL=https://YOUR-LYNTWAY/gw/anthropic ANTHROPIC_AUTH_TOKEN=$LYNTWAY_KEY ANTHROPIC_API_KEY=$YOUR_ANTHROPIC_KEY # unchanged
lyntway init writes both shapes into your shell profile, and lyntway status reports a shell as routed only once the credential in it is one the gateway will accept.
Do not paste a snippet into a new file. That governs only the calls inside it while the rest of the application carries on reaching the provider directly — which looks exactly like the product not working.
2. Only if your code sets the URL and key itself
Change it where the client is built, and everywhere it is built.
client = OpenAI(
base_url="https://YOUR-LYNTWAY/gw/openai",
api_key=LYNTWAY_KEY, # we authenticate on Authorization
default_headers={"X-Provider-Key": OPENAI_API_KEY},
)Anthropic is simpler — its own x-api-key passes through untouched, so only the base URL and an Authorization header are needed.
3. Tools with one API key field
Cursor and most editor integrations offer a base URL and a single key box, with no way to add headers. Join the two keys with a tilde and paste that in — we split them, forward yours, and keep neither.
A limit worth knowing before you rely on this. In these editors a base URL applies only to models you add with your own key. Whatever comes bundled with their subscription is routed to their own servers, and nothing you configure here changes that. This covers a team bringing its own provider keys; it does not cover a team on the editor's plan.
Base URL https://YOUR-LYNTWAY/gw/openai/v1 API key $LYNTWAY_KEY~$OPENAI_API_KEY
4. MCP servers on a laptop
Most MCP servers run as a subprocess of the agent over a pipe, where no proxy can see them. Point the agent at the shim and it spawns the server itself.
{"mcpServers": {"github": {
"command": "lyntway-mcp",
"args": ["--", "npx", "-y", "@modelcontextprotocol/server-github"]
}}}The shim needs no account. Run on its own it signs with a key it makes at startup and throws away, which is useful for seeing what your own tools handle and worth nothing as evidence to anybody else — the key belongs to the person being audited. Sign in and it signs with a key that does not.
Tool results are governed on the way back, which is the direction that matters: a result carrying a customer's details is about to become part of a prompt. Detection happens on that machine — the content is never sent anywhere to be inspected.
Classes and counts are reported to your account once the machine has been signed in with lyntway login, so laptop activity appears in Traffic beside everything else. Nothing else leaves: not the values, not the tool's arguments, not a digest of the result. Those receipts are recorded as attested — the shim watched it and told us, which is weaker than us having watched it, and the receipt says so.
5. Already running a gateway
Portkey, Kong, LiteLLM, Cloudflare — keep them. Add yours as an endpoint in the console and point your application at us instead. They carry on routing; we see the traffic on the way past.
Turn on watching-only first if they already inspect this traffic. Otherwise we substitute values before their guardrails ever see them, and you keep paying for a check that finds nothing.
6. AWS Bedrock and Google Vertex
pip install lyntway litellm_settings: callbacks: [lyntway.litellm.handler] environment_variables: LYNTWAY_URL: https://YOUR-LYNTWAY LYNTWAY_KEY: $LYNTWAY_KEY
Neither can be put behind a proxy: both sign every request in a way that breaks the moment something sits in the path. Route them through a gateway that signs for you, add the Lyntway plugin to it, and every call is recorded on the way past.
Lyntway does the detection and the signing, as it does everywhere else — the plugin runs our rules and our models over the content and returns a receipt signed with your key. What it does not do is watch the call leave, because your gateway made it, so the action is recorded as asserted rather than observed. Attribution comes from your gateway's own key, so a finding lands on the person who caused it.
It records and never blocks: the callback runs after the response is back, so there is nothing left to change. If we are slow or unreachable the call still succeeds and the receipt is simply missing, which the coverage report shows. A recorder that can fail your traffic is one you would remove.
7. Anything already emitting OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=https://YOUR-LYNTWAY OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer $LYNTWAY_KEY
Every service that speaks it appears, with no integration written for any of them. Evidence is attested rather than observed — we vouch for having received the report, not for having watched the traffic.
The laptop
Three things happen on a developer's machine that no server-side gateway ever sees: a model runs locally, a key gets pasted into a prompt, and a key gets committed. Each has a command, and each is careful about what leaves the machine.
Ollama and LM Studio
lyntway proxy # listens on 127.0.0.1:11435 lyntway proxy --upstream http://127.0.0.1:1234 # LM Studio instead of Ollama export OPENAI_BASE_URL=http://127.0.0.1:11435/v1 lyntway-verify -chain -keys ~/.lyntway/receipts/keys.json \ ~/.lyntway/receipts/proxy-ollama.jsonl
A listener on the laptop, in front of the local model server. Every request body and every reply — streamed replies included — is governed by the same engine the gateway runs, with the default policy, and the receipt is written to ~/.lyntway/receipts/. Ollama and LM Studio both speak the OpenAI shape, so the one variable above routes every SDK that reads it. lyntway status says whether the proxy is running and what it fronts.
Who signed it. The receipt is signed on the machine, with the key lyntway keys sign registered with your account if there is one, and otherwise with the key lyntway-mcp keeps, which exists nowhere but that laptop. The first can be tied to the account by anyone holding its record; the second only to the key file published beside the receipts. Both are a laptop's key, worth less than a receipt signed by this service, and the issuer on every receipt says which. Signed in, the proxy reports classes and counts to your account, which records them as attested — the laptop watched and told us. Nothing else leaves the machine: not the prompt, not the reply, not a digest of either, and --report=false keeps even the counts there.
A key pasted into a prompt
lyntway hook install # Claude Code: a UserPromptSubmit hook in ~/.claude/settings.json
lyntway hook install --cursor # Cursor: beforeSubmitPrompt in ~/.cursor/hooks.json
$ echo '{"prompt":"use sk-ant-api03-…"}' | lyntway hook prompt
That looks like an Anthropic key (…k3Qx). It was not sent.
Store it with `lyntway keys migrate` or paste a Lyntway key instead.The editor runs the hook on every prompt before the prompt is sent. The hook reads the prompt on standard input, runs the credential rules over it, and on a match refuses the prompt — Claude Code takes an exit status as the refusal, Cursor a JSON answer — so the key is stopped on the machine and reaches no model. Nothing is sent anywhere, to us or to anyone: the prompt is read and dropped. Installing asks first, keeps a backup of the settings file beside it, and lyntway hook uninstall or lyntway undo puts it back.
Keys in a repository
lyntway scan # the working tree lyntway scan --diff origin/main # only what this branch adds lyntway scan --format github # ::error annotations for a workflow
Finds provider keys by the shape of the value and by the name of the variable holding it, prints file:line, the provider and the last four characters, and exits 1 when anything is found. It never prints the value: a scanner that echoes the secret it found into a build log has moved the problem, not removed it.
The GitHub Action
# .github/workflows/lyntway.yml
on: pull_request
jobs:
keys:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: lynt-x-global/lyntway-tools/action@mainThe scan above, on every pull request, against the base commit. A key added by the branch fails the job with the file, the line and the fix printed as an annotation. The action downloads the release for the runner's platform and checks it against the published SHA256SUMS before running it. It needs no Lyntway key and calls no service: it is a scan, not a report.
A badge
[](https://lyntway.com)
For a README. It says the repository routes its AI calls through Lyntway, which is a statement by the repository and not by us; a badge is a decoration, not a receipt, and it carries none of the three provenance colours so nobody can mistake it for one.
Deploying it
Everything above changes configuration, not source. So putting Lyntway into a real deployment is putting two environment variables where that deployment already keeps them, and restarting.
Docker
# In the image ENV OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 # Or at run time, which keeps the secret out of the image docker run -e OPENAI_BASE_URL=... -e OPENAI_API_KEY=... your-app
Docker Compose
services:
api:
environment:
OPENAI_BASE_URL: https://YOUR-LYNTWAY/gw/openai/v1
OPENAI_API_KEY: ${LYNTWAY_KEY}Kubernetes
A Secret and an envFrom. One manifest change, rolled out with your next deploy.
apiVersion: v1
kind: Secret
metadata: { name: lyntway }
stringData:
OPENAI_BASE_URL: https://YOUR-LYNTWAY/gw/openai/v1
OPENAI_API_KEY: lynt_sk_...
---
# in the Deployment's container spec
envFrom:
- secretRef: { name: lyntway }Vercel, Railway, Render, Fly, App Service
The environment-variables panel, then restart. No code deploy is needed, because no code changed.
Continuous integration
Set the same two as repository or organisation secrets. Test runs that call a model are then governed too, which is usually where the first surprise about what your test fixtures contain turns up.
Frameworks
Every agent framework builds an OpenAI client underneath, and each has one option for telling that client where to go. The exact option for the Vercel AI SDK, LangChain, LlamaIndex, CrewAI, the OpenAI Agents SDK, Mastra and Google ADK is on the integrations page, checked against each framework's own documentation. Some also read the environment — LangChain's ChatOpenAI falls back to OPENAI_API_BASE and then OPENAI_BASE_URL, and CrewAI reads OPENAI_BASE_URL — and for those the two variables above are enough.
Where the option is passed in code, pass it where the client is built — everywhere it is built. A single new file governs only the calls inside it.
One secret instead of two
The tilde exists for editors that give you a single key field. A server deployment has headers and a secret manager, so it should not need it: store your provider key once under API keys, and the application carries only ours.
OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 OPENAI_API_KEY=$LYNTWAY_KEY # no tilde, no provider key
This is worth more than the tidier configuration. An application that holds no provider credential cannot reach the provider directly, so “our AI traffic is governed” stops depending on everyone remembering to route politely. Try it: point the base URL back at the provider and the call is refused, because nothing in the application can authenticate to them any more.
The trade is stated rather than buried. Until you store one, this service holds no credential of yours at all, which is the stronger answer in a security review. Self-hosted, the key never leaves your own estate and there is no trade to make.
If your client sets its own headers, X-Provider-Key carries the provider's key separately and needs neither the tilde nor stored keys.
Keys
Two kinds of key meet on the API keys page. A Lyntway key names a caller. A provider key is the upstream's own secret, stored with us so the caller need not carry it. Everything below is about keeping the two apart and saying, on every receipt, which one was used.
One key per application
Issue a key for each application and label it with the application's name. Every receipt then names the key that presented it, usage under each provider key is broken down by caller, and revoking one stops one application rather than all of them. A key shared between two services is a key whose receipts cannot say which of them acted.
Allowlists
A key may be limited to the upstreams it names, at issue or later. A request from that key to any other upstream is refused with upstream_not_allowed before anything is governed, so no receipt is written for it — there is nothing to record — and a signal of kind upstream_refused is kept instead. A key with no allowlist may call every upstream the account can reach, which is what every key issued before allowlists existed does, and an absent list is never read as a restriction.
POST /v1/keys
{"label": "intake-api", "upstreams": ["openai", "lawmatics"]}
PATCH /v1/keys/{key_id}
{"upstreams": ["openai"]} # [] means every upstream againPer-application provider keys
A provider key is stored for the whole account or bound to one Lyntway key. A request that carries no provider key of its own gets the one bound to the key it presented, and only falls back to the account-wide one when none is bound — an application never picks up a sibling's key. The receipt's recent event records which was used, so the usage beside each stored key can say how many calls came through the application's key and how many through the account's.
POST /v1/keys/providers
{"upstream": "lawmatics", "key": "...", "key_id": "<intake-api key id>", "note": "intake only"}
DELETE /v1/keys/providers/lawmatics?key_id=<intake-api key id>A key placed on the request always wins over a stored one. Changing which credential a running service uses is not a decision made on its behalf.
Expiry, replace, revoke
A stored provider key can carry the date it stops being used. Past it, a request relying on the stored key is refused with provider_key_expired before governing and before any receipt; the refusal names the upstream and the date. Replace stores a new value under the same upstream and the old one stops existing. Remove takes it away, and every application relying on it needs to carry its own again.
None of this is rotation. Nothing here mints a new key at the provider. If the provider's console is where new keys come from, it still is; what a date on a stored key gives you is a key somebody has to come back to.
Signing requests
A Lyntway key is a bearer credential: whoever holds the value is accepted, and the receipt's actor is whatever the caller asserted. Registering an Ed25519 public key against a Lyntway key changes that. Every request from that key must then carry an HTTP message signature (RFC 9421, the Web Bot Auth profile) made with the matching private key, over the method, authority, path and — when there is a body — a digest of the body. A valid signature records the receipt's actor as web_bot_auth and verified. A missing, expired or wrong one is refused with bad_signature, and a signature that leaves the body uncovered is refused too, because it proves less than it appears to.
lyntway keys sign --key-id <key_id> # writes ~/.lyntway/keys/<key_id>.pem (mode 0600), registers the public key, # and adds these to the shell profile block lyntway init maintains: export LYNTWAY_SIGNING_KEY=~/.lyntway/keys/<key_id>.pem export LYNTWAY_KEY_ID=<key_id>
The Python and TypeScript SDKs read those two variables and sign every request. Somebody using their own HTTP client can call sign_request / signRequest directly. The registration itself is one call, which the CLI prints if the server does not yet accept it:
POST /v1/keys/{key_id}/signing
{"public_key": "<base64 of 32 raw bytes>", "alg": "ed25519"}
# remove the registration; the key goes back to bearer-only
POST /v1/keys/{key_id}/signing/clearClearing is a POST to …/signing/clear rather than a DELETE on …/signing: the API cannot tell that DELETE apart from removing a provider key, so the two shapes differ on purpose. The console's own route uses DELETE, which has no such neighbour.
What it proves, and what it does not. A verified signature proves the request came from something holding that private key, and that the body was the one signed. It does not prove which person or machine that was: the private key is a file, and a copy of it signs just as well. Keep it at mode 0600. It is not held in a keychain or a secure enclave, and the CLI's help says so. A key with no registered public key is accepted exactly as before, and the receipt's actor for it stays asserted.
Connecting a terminal
lyntway login with no key asks the server for a link code, prints the address and opens it in a browser. Signed in, the page shows the machine's name and asks whether to connect it. Approving issues a Lyntway key labelled with that name — it appears on the API keys page like any other — and hands it to the waiting terminal once. The code is single-use and expires after ten minutes. A deployment that does not offer self-service signup does not offer links either, and the CLI falls back to asking for a key.
Moving keys out of .env files
lyntway keys migrate [dir]
Reads .env, .env.local and .env.development in the directory, recognises provider keys by variable name and by the shape of the value, and prints the plan — variable, upstream, last four characters — before anything is written. On yes, each key is stored with us with a note saying which file and machine it came from; the file is rewritten so the variable carries the Lyntway key and a base-URL line points at the gateway; and the original is saved beside it as .env.lyntway-backup-<timestamp>, which is added to .gitignore when one exists. The original value is written nowhere else. lyntway undo restores every rewritten file from its backup byte for byte. If a key is already stored for that upstream the CLI asks before replacing it, and it never prints a key value.
Automatic hold
A tenant can set thresholds at which a key is held: a number of refusals in an hour, a multiple of the key's usual hourly volume, or a receipt naming a destination the key had not reached before. A held key is not revoked. Every request it makes is refused with key_held and the reason, nothing is forwarded, no receipt is written, and a signal, an audit entry and an alert record the hold. Releasing it from the API keys page, or with POST /v1/keys/{key_id}/release, puts it back to work and is audited too.
The limit. The counters behind a hold live in the memory of the process that saw the traffic. They start empty when it starts, and a deployment running several replicas counts on each replica separately, so a threshold is crossed later than the total across them would suggest. A hold is a tripwire on what one process has seen, and the console says so beside the setting.
Workload identity
An application running in Azure need not hold a Lyntway key at all. It presents the JWT its managed identity already has, and the gateway verifies it against Entra ID: the issuer, the audience you configured, the validity window, and that the application id is one you listed. Verification is against the tenant's published signing keys, fetched through OIDC discovery and refreshed when an unknown key id appears, so Microsoft changing its signing keys needs nothing from you.
POST /v1/identity
{"provider": "entra",
"tenant_id": "<Entra tenant id>",
"audience": "api://lyntway",
"allowed_app_ids": ["<app id>"],
"acts_as_key_id": "<the Lyntway key whose upstreams, allowlist and policy apply>"}
# the application, with no Lyntway key anywhere in its configuration
Authorization: Bearer <managed-identity JWT>A verified token acts as the Lyntway key named in acts_as_key_id — its upstreams, its allowlist, its policy — and the receipt's actor is recorded as the Entra application, verified, rather than whatever the caller said about itself. The gateway tells a managed-identity token from a Lyntway key by its shape, so both work on the same endpoint. GET /v1/identity reads the configuration back and DELETE /v1/identity clears it; the console has the same three under Settings.
Which audience to configure. A managed identity receives a v1 token by default, whose aud is the Application ID URI of the app registration that stands for Lyntway in your directory (api://…); an app registration that asked for v2 tokens gets aud set to that registration's client id instead. The audience is matched exactly against whichever the token carries, and the issuer is accepted in both forms (sts.windows.net for v1, login.microsoftonline.com/…/v2.0 for v2). The application id is read from appid on a v1 token and azp on a v2 one.
What a token cannot do. It acts as a key and may not administer the account: /v1/keys, /v1/identity, /v1/tenant and any write to /v1/agents answer 403 with reason scope, so a workload allowed to call a model cannot issue itself a permanent key. Every other refusal is 401 and carries a machine-readable reason beside the sentence — audience, issuer, expired, app_not_allowed, signature, unknown_key, no_configuration among them — so a client can branch on it:
401 {"error": {"code": "workload_token_rejected", "message": "…"}, "reason": "audience"}
403 {"error": {"code": "workload_token_rejected", "message": "…"}, "reason": "scope"}Entra ID today; AWS IAM and SPIFFE are next. An application on another cloud carries a Lyntway key until then.
Approvals
A policy rule can say require_approval for a class. A request that matches is parked rather than refused: a record is written naming the key, the upstream and the findings as classes and counts, an alert goes out, and the gateway waits for somebody to decide.
# on the request: how long to wait for a decision, in seconds (default 30, at most 120)
X-Lyntway-Approval-Wait: 60
# on the response, as soon as the request is parked
X-Lyntway-Approval: apr_…
GET /v1/approvals # pending, approved, denied, expired
POST /v1/approvals/{id}/approve
POST /v1/approvals/{id}/deny
# refused
403 {"error": {"code": "approval_denied", "message": "…", "approval_id": "apr_…"},
"approval_id": "apr_…", "receipt_id": "rcpt_…"}
403 {"error": {"code": "approval_timeout", "message": "…", "approval_id": "apr_…"},
"approval_id": "apr_…", "receipt_id": "rcpt_…"}
403 {"error": {"code": "self_approval", "message": "…"}} # deciding with the key that askedTwo receipts are issued, because two things happened. The hold is receipted first, with decision require_approval and nothing released. The resolution is a second receipt on the same chain, carrying that decision and a governance.approval block — {id, outcome, decided_by, decided_at}, outcome approved, denied or expired. Approved within the wait, the request goes on to the provider and that second receipt is the one returned for it. Denied, or unanswered when the wait runs out, the caller gets 403 with approval_denied or approval_timeout, the approval id and the id of the receipt whose decision block records the same word as its refusal. An approval can be decided only while its caller waits; when the wait runs out it closes as expired, and a decision after that is refused with approval_expired, because there is no longer a request to release.
Decisions are made from the console's approvals page (the owner role) or the two endpoints above, and are audited. A key cannot approve its own request: deciding with the key that asked is refused with self_approval, so on a deployment whose only keys come from configuration a hold is decided from the console. Two people deciding at once is settled by the first to land; the second is told already_decided, by whom and when.
Where the body is while it waits. In the memory of the process that received it, for the wait window and no longer. It is never written to the store: the approval record holds classes and counts, and somebody reading the list of pending approvals sees what kind of thing was found, not what was said.
Token accounting
Not the substitution tokens below — the input and output token counts a provider reports on each response. The gateway reads them from the response it already relays (OpenAI's usage, Anthropic's usage and streaming deltas, Gemini's usageMetadata), records them on the event, and folds them into usage per key, per agent and per upstream.
GET /v1/usage
… "tokens": {
"calls": 212, "input": 18422, "output": 3105,
"reported_by": "provider",
"by_key": [{"actor": "billing-service", "calls": 140, "input": 12010, "output": 2100}],
"by_agent": [{"agent_id": "…", "calls": 12, "input": 900, "output": 140}],
"by_upstream": [{"upstream": "openai", "calls": 212, "input": 18422, "output": 3105,
"estimated_cost": {"amount": 0.0921, "currency": "USD",
"basis": "estimate at the price you entered per million tokens; not a provider figure",
"price_per_million_in": 2.5, "price_per_million_out": 10}}],
"estimated_cost": {"amount": 0.0921, "currency": "USD", "basis": "…", "unpriced_upstreams": ["anthropic"]},
"durable": true, "note": "…"} …They are the provider's numbers, and they are labelled as such. We did not count tokens; the provider said how many there were, and reported_by is provider on every figure — the only value it takes. A call whose response carried no usage figure is counted as a call and not as tokens: on a streamed OpenAI response the count only exists when the request asked for it with stream_options.include_usage, and a stream without it records no tokens rather than a guess.
No dollar figure appears anywhere unless you enter a price per million tokens against an upstream (PUT /console/upstreams/{name}/price, or the Prices card on the usage page). Then, and only then, an estimated_cost appears beside the counts, and its basis says on every line what it is: an estimate at the price you entered per million tokens, not a provider figure. The period total names any unpriced_upstreams it leaves out, and is omitted altogether when two prices were typed in different currencies, because those cannot be added.
Two modes
| Mode | What happens | Choose it when |
|---|---|---|
| Watching only | Detects, records, signs. Content passes through unchanged. | Something else already protects this traffic |
| Protecting default | Also substitutes values before content leaves. | Nothing else is in the path |
A request your policy refuses is refused in both. The receipt says which mode was in effect, so it never reads as though protection was available and simply did not trigger.
How verification works
This is the part that matters, and it holds up on its own. The tools are public and free, under Apache-2.0, and checking a receipt needs no account — an auditor is not a customer and should not have to become one:
brew install lynt-x-global/lyntway/lyntway
Builds for other platforms are available on request. The tool has no third-party dependencies at all, so what goes into the thing that checks receipts can be read in an afternoon.
$ lyntway-verify -keys keys.json receipt.json VERIFIED decision redact findings pci.card_number ×1 → redact, pii.email ×1 → redact signed by lyntway-t-6d48e64e… (ed25519)
Fetch the key once from /.well-known/lyntway-keys.json and the check runs offline forever after. No account, no network, nothing further from the issuer — which is the point: evidence that requires the issuer's permission to verify fails exactly when the issuer is the party in dispute.
Any RFC 8785 and RFC 9052 implementation works. There is also a browser page at /verify for convenience, and a public endpoint — but both require trusting us, which makes them the weakest of the three.
A receipt newer than your verifier
The schema gains values over time — a new surface, a new kind of actor. A verifier you installed last year will meet one eventually, and it must not report that as tampering, because a reader cannot tell the two apart. So a receipt carrying a word the tool does not know still verifies, and says so plainly:
$ lyntway-verify -keys keys.json receipt.json
VERIFIED — NEWER THAN THIS VERIFIER
! action.surface is "voice", which this build does not recognise: the
signature is valid and the bytes are unaltered, but this verifier
cannot say what that value means. A newer build may.The signature still had to check out; nothing about that is relaxed, and a receipt altered after signing fails exactly as before. What the tool declines to do is vouch for a receipt it cannot read completely, so the result is flagged as partly unread rather than clean. Upgrading the tool is the remedy, and the receipt itself never needed one.
Three fields are deliberately outside this: governance.mode, governance.decision and evidence.provenance. They say how much the receipt claims, so a verifier that cannot read them refuses outright rather than reporting on a strength it cannot establish.
Checking one from scratch, with no tool of ours
Everything needed is in the receipt and the published key file. Two signatures are involved, and the order matters.
- Confirm the signing key is ours. Each account signs with its own key, and
issuer.key_attestationis our root key vouching for it. Canonicalise that block with itssignaturefield removed, prepend the domain separatorlyntway-key-attestation-v1\x00, and check it against the root key named inroot_key_id, which you fetch from/.well-known/lyntway-keys.json. - Confirm the receipt matches its signature. Canonicalise the receipt with its own
signaturefield removed, and check it againstkey_attestation.public_key. No domain separator here: the receipt is signed over its canonical bytes directly. - Check the dates and the revocation list. The attestation's
not_beforeandnot_aftermust contain the receipt'sissued_at. The key file'srevokedlist names keys we have withdrawn: a key markedcompromisedvouches for nothing it signed, and a retired key only for receipts dated before itsrevoked_at. Check both the signing key and the root key. Save the key file with the receipts, and fetch it again when you check them later.
The domain separator on the first is deliberate. Without it, a signature made over one kind of statement could be replayed as though it were the other — a standard precaution, and the one detail somebody reimplementing this will otherwise spend an afternoon on.
Canonicalisation is RFC 8785: keys sorted by UTF-16 code unit, no whitespace. Signatures are Ed25519 (RFC 8032), base64 in the JSON. The whole check is about thirty lines against any standard crypto library, and it depends on nothing we publish except the public key.
What a receipt proves, and what it does not
A receipt that verifies proves a narrow set of things very firmly. It is worth knowing exactly which, because the value of the evidence is that it never reads stronger than the truth — and a reader who assumes more than it says will eventually be corrected by somebody hostile.
| A verified receipt proves | It does not prove |
|---|---|
| That the key named in it signed exactly these bytes, and nothing in them has changed since. Change one character and the check fails. | That the key's holder told the truth when signing. It proves the record is unaltered, not that the record was right. |
| That the signing key was vouched for by our published root key, within the dates on its attestation, and has not been withdrawn — as of the key file you checked against. | That the key was not withdrawn after you saved that key file. Fetch it again when you check old receipts. |
What the governing engine found, as classes and counts, what it decided, and which of its components ran (governance.mode). | That nothing else was in the content. Detection misses things and finds things that are not there; a finding with a named detector is a model's opinion, not a rule's match. |
The SHA-256 of what came in and what went out (content.input_digest, output_digest), so a copy of the content you hold can be matched to it. | What the content said. A receipt carries digests, never the text, so on its own it cannot show what was sent. |
How the issuer knew: observed (we handled the bytes), attested (another system reported it) or asserted (the caller described itself). | That an attested or asserted description was accurate. Those receipts prove the description was governed, and are only as good as whoever gave it. |
Where it sits in its chain (chain.seq, prev_hash), so a receipt removed from the middle of a chain you hold is detectable. Hosted receipts are also in the public transparency log. | That this was all of your AI traffic. A receipt evidences what was governed. Anything that never passed through is not in it, and /v1/coverage says what is known to be missing. |
| When it was issued, by the issuer's clock — and, when an RFC 3161 anchor is attached, by an outside timestamp authority's too. | A time independent of us, when no anchor is attached. The verify page says so on every receipt without one. |
Who the actor was, when actor.verified is true. | Who the actor was, when it is false: the name was supplied, not checked. |
And one thing it is never meant to prove: that an organisation is compliant with anything. A receipt is evidence that a control operated on the traffic it covers. Compliance is an auditor's judgement, and Lyntway holds no certification of its own.
What a receipt actually claims
Three fields carry the weight, and absent always reads as the weakest possibility.
evidence.provenance
observed | We were on the wire and saw the bytes ourselves |
attested | Another system reported it. As good as your trust in that system |
asserted | The caller described its own action |
governance.mode
full, degraded or bypassed.full means every component configured on that deployment ran — not that every tier the product has was present. A deployment with no model tier issues full receipts whosegovernance.detector.components lists the deterministic tier alone. A receipt issued while a configured model tier was down says degraded and names the missing part rather than claiming a scan that half happened. Read components for which tiers were configured, and mode for whether they ran.
The detector on each finding
An empty detector means the deterministic ruleset — reproducible, because the ruleset digest on the receipt lets anyone re-run the same patterns months later. A named detector means a model formed an opinion: better recall, no guarantee that re-running agrees. Both are real findings. They are not the same kind of evidence.
What it cannot see
Stated here rather than left to be discovered, and published per account at /v1/coverage:
- Traffic that never reaches us. A receipt evidences what was governed. It does not evidence that this was everything.
- Where a chained gateway sent it next. If you route through us into Portkey, we record that your data reached Portkey. What they did with it afterwards is theirs to say.
- Names on a laptop without a local analyzer. The pattern rules catch anything with a checksum or a vendor prefix; a person's name needs a model.
- Tools that cannot be redirected. JetBrains AI Assistant and a browser tab on a personal account are invisible, and no configuration changes that.
- Remote MCP servers that sign you in with OAuth. The token your client receives is bound to that server's own address, so it is refused when the request arrives from ours. Servers that authenticate with a header or an API key go through the gateway normally; an OAuth one has to be reached directly, and is not in your receipts.
- Anything that has to be intercepted rather than configured. On the hosted service there is no transparent proxy — every path in is one somebody pointed at us. A self-hosted deployment can run the CONNECT listener and cover a whole machine; the hosted one cannot, because the platform in front of it does not carry that method.
Detection is imperfect in both directions. Model findings produce false positives on ordinary business writing — “ignore the second column, it is a duplicate” reads as an injection attempt to a classifier. That is why injection findings are recorded and never enforced on by default.
What we store
| Kept | Not kept |
|---|---|
| Classes and counts of findings Receipt digests Your account and key fingerprints | Your prompts and responses, unless you turn that on The values we detect, unless you turn on reversible tokens Your provider credentials, unless you store them with us The receipts themselves, unless you configure a destination |
Storing the content itself is a switch, and it is off. Settings → Controls can turn it on for your account, and then the exact request and reply are kept alongside each receipt — which is what somebody reviewing an incident usually wants, because a receipt proves what happened without revealing what was said. Anyone who can sign in and read your traffic can then read them, so it belongs in a privacy review before it belongs in a rollout.
Neither direction is retroactive. Turning it on does not recover content from before, and turning it off does not delete what is already stored.
Sealed mode: we read your traffic to protect it, and keep nothing we can read
Kept content is encrypted at rest either way. Sealed mode goes further: the console makes a key pair in your browser, sends us only the public half, and locks the private half separately for each of at least two custodians, each with a phrase we never receive. From then on every stored request, reply and governed body is sealed to that public key and bound to its receipt, and the traffic view hands the ciphertext to your browser to open. This service can write that content and cannot read it.
What that does not cover, stated so the sentence above is not read as more than it is. We read content in flight, because detection and substitution need the bytes. The facts on each receipt — who, when, where, which classes, how many — stay readable, which is what lets the console, packs and alerts work. Token mappings, if you turn on reversible tokens, are encrypted but not yet sealed. Provider keys you store with us are opened by the service to put them on your requests. Lose every custodian's phrase and the sealed content is gone for everybody; receipts and packs are unaffected, because they carry no content. Turning sealing off stops new sealing and leaves what was sealed, sealed.
Decide where your receipts go before you rely on them.Without a destination they are returned to your application and retained by nobody — we hold their digests, so a receipt you kept can be checked, but one nobody kept cannot be produced by us. Two ways to keep them are below.
Getting hold of your receipts
Every governed action produces a signed receipt. By default it is returned to your application and then kept by nobody — we hold its digest and nothing else. That is deliberate, and it means a new account can generate evidence all week and have none of it when somebody asks.
Two ways to keep them, and a deployment should choose one on day one.
Send them somewhere as they are issued
Settings → Receipt forwarding. Every receipt is posted to an address you control, as it is signed. Nothing to change in your application, and it covers traffic from tools and laptops that no application code touches.
Straight into Splunk or Datadog
Forwarding has a preset that shapes the post the way each collector expects, so nothing sits between us and it.
| preset | What is sent |
|---|---|
splunk_hec | POST to /services/collector/event on your endpoint, Authorization: Splunk <token>, body {"event": <receipt>, "sourcetype": "lyntway:receipt"} |
datadog_logs | POST to https://http-intake.logs.<site>/api/v2/logs with DD-API-KEY, ddsource lyntway, receipts in an array |
webhook | The receipt as it is, to the endpoint and auth header you gave — what forwarding always did |
Batching and retry are the same for all three. A test button sends one receipt marked as synthetic and shows the status the collector answered with, so a wrong token is found in the console rather than in an empty dashboard a week later.
A pack on a schedule. Settings → Export can send the compliance pack weekly or monthly to an address you name. The mail carries a link to the pack rather than the pack itself, because the mail service does not carry attachments, and the setting says so where you turn it on. A pack is evidence once its attestation verifies, wherever it was fetched from. A monthly send covers the calendar month that just ended, and only that month; a weekly send covers the month so far, and the pack's signed covers section says it is not yet complete.
Or take them on the response
Ask for them per request and they come back on the response, so your application can store them wherever it already stores anything.
# on the request X-Lyntway-Receipts: inline # on the response — base64 of the receipt JSON X-Lyntway-Receipt-Request-Body: eyJ2ZXJzaW9uIjoi... X-Lyntway-Receipt-Response-Body: eyJ2ZXJzaW9uIjoi...
Base64 because a header may not carry newlines and a receipt's JSON does. Two receipts because the request and the response are separate events with separate decisions.
Three headers come back whether or not you ask, and are enough to log which receipt to go and find later:
X-Lyntway-Request-Receipt: rcpt_046bdef1274e1191763b0ff4 X-Lyntway-Request-Decision: redact X-Lyntway-Chain: gw/openai
On a streamed response the response receipt cannot exist until the stream ends, so it arrives as the last event — with whether the stream was cut short, which a stream that merely stops cannot tell you:
event: lyntway
data: {"truncated":false,"receipt":"rcpt_046bdef..."}Alerts
Settings → Alerts & holds. A handful of events are worth interrupting somebody for: a credential in traffic, a refused request, a key put on hold, a request waiting for approval, detection impaired for an hour, a receipt your log never received, an MCP server changing its tools. Each is posted as JSON to the destinations you choose — up to ten, each with its own events — and never carries a value: classes and counts, as on a receipt, and the id of the receipt to go and look at.
{
"version": "lyntway-alert/1",
"id": "msg_2b8f0c6d1e4a9f7b3c5d8e01",
"trigger": "secret",
"tenant_id": "t_…",
"at": "2026-09-19T12:00:00Z",
"summary": "A credential was detected in traffic to api.openai.com and the decision was redact.",
"text": "…the same sentence, for Slack and Teams incoming webhooks…",
"destination": "api.openai.com",
"decision": "redact",
"classes": {"secret.aws_access_key": 1},
"receipt_id": "rcpt_…"
}Checking an alert came from us
Every delivery is signed with its destination's own secret, shown once when the destination is added and never again. The scheme is Standard Webhooks, so any of its libraries verifies it with no code of ours:
| Header | What it is |
|---|---|
webhook-id | The message id, the same as id in the body. Unchanged across retries of one alert, so you can discard a duplicate. |
webhook-timestamp | Seconds since the epoch when this attempt was sent. |
webhook-signature | v1, then base64 of HMAC-SHA256 over id.timestamp.body, keyed with the base64-decoded part of your whsec_ secret. Two signatures, space-separated, for a day after you rotate. |
Three rules make it worth checking. Compute the MAC over the body exactly as it arrived, before any JSON parser touches it. Refuse a timestamp more than five minutes from your clock, which is what stops a captured delivery being replayed later. Remember the webhook-ids you have handled for at least that long, and ignore a repeat.
Node, with nothing but the standard library:
import crypto from "node:crypto";
// secret: the whsec_… value shown when the destination was added.
// rawBody: the request body as a Buffer, before JSON.parse.
export function verifyLyntwayAlert(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (!id || !ts || !(age <= 300)) return false; // stale or malformed: refuse replays
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const want = "v1," + crypto.createHmac("sha256", key)
.update(`${id}.${ts}.`).update(rawBody).digest("base64");
return (headers["webhook-signature"] || "").split(" ").some((sig) =>
sig.length === want.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)));
}Python:
import base64, hashlib, hmac, time
def verify_lyntway_alert(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
try:
if abs(time.time() - int(ts)) > 300:
return False # stale: refuse replays
except ValueError:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256)
want = "v1," + base64.b64encode(mac.digest()).decode()
return any(hmac.compare_digest(sig, want)
for sig in headers.get("webhook-signature", "").split())Go:
func verifyLyntwayAlert(secret string, header http.Header, body []byte) bool {
id := header.Get("webhook-id")
ts := header.Get("webhook-timestamp")
sent, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(sent, 0)).Abs() > 5*time.Minute {
return false // stale or malformed: refuse replays
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, sig := range strings.Fields(header.Get("webhook-signature")) {
if hmac.Equal([]byte(sig), []byte(want)) {
return true
}
}
return false
}Why a shared secret and not the Ed25519 key that signs receipts: an alert is a notification, and the evidence is the receipt it names, which already verifies on its own. A per-destination secret answers the question a receiver has — did this come from Lyntway, for my account, just now — can be rotated by you without touching any key that signs evidence, and keeps that key signing evidence and nothing else.
Retries, and what the log says
A delivery answered with a 5xx, 408, 425 or 429, or lost to a network failure, is tried again after about 10 seconds, 40 seconds, 2½ minutes, 11 minutes and 43 minutes, then every two hours, each with ±20% jitter: eight attempts at most, within twelve hours of the alert. A Retry-After header is honoured; one asking for longer than the twelve hours have left ends the attempts. Any other answer is final at once, because a 401 on the eighth try is the 401 it was on the first. Waiting alerts are stored, so a restart or a deploy does not lose them.
Each destination keeps a log of its last hundred attempts: when, which try, the status, and a sentence of ours. Never the body, the headers or the address, which for most webhook services is itself the credential. The test button sends one alert with trigger test through the same path, signed, and reports the status your endpoint answered. Addresses inside a private network, loopback or a cloud metadata service are refused when you add them and again when each delivery connects.
Evidence in Vanta and Comp AI
Settings → Receipts → Evidence destinations. On the first of each month the month that ended is pushed to Vanta, Comp AI or both, and a Push now button sends it whenever you like. What arrives is the month's signed pack and nothing stronger: counts by who observed them, the attestation that signs them, the controls it speaks to, the controls it is silent on, and every limit the pack states about itself. A GRC tool files what it is given as “evidence”, so the not-covered lines travel inside it.
| Tool | What it receives | How it is kept idempotent |
|---|---|---|
| Vanta | One custom resource per month through a private integration (PUT /v1/resources/custom_resource), and optionally each finished month's signed bundle as a file on a Vanta document. | Vanta's sync replaces the whole set, so every push resends the months already sent, unchanged, under a fixed uniqueId. A month's bundle is uploaded to the document once. |
| Comp AI | The month's bundle — pack.json, keys.json,README.txt, SHA256SUMS — attached to each task you name, with a description that says what it does not cover. | The file's name is fixed per month and the task's attachments are read first; a file already there is left alone. |
Vanta needs a private integration: in Vanta, Settings → Developer Console, a Build Integrations app with Private distribution. In its Resources tab create a resource with the Custom Resource base type and paste the schema the console shows as its custom properties; the flat fields are what a Vanta custom test can check, such as periodComplete or credentialFindings. Give Lyntway the client ID, the client secret and the Resource ID. Vanta issues one token per application at a time, so a push takes a fresh one and nothing else should be using that app's credentials.
Comp AI needs an API key with access to tasks, the IDs of the tasks the evidence belongs to, and your own Comp AI user ID, because Comp AI attributes an upload made with a key to a member. If you run Comp AI yourself, give its API address; it is checked like any address you type here.
The credential is sealed when you save it and never shown again; the console shows its last four characters. Test proves the credential and the IDs without writing anything. A month still under way is marked so, and a month the pack calls incomplete — a deployment that keeps activity only in memory never claims a whole month — says so in the resource and in the file. A scheduled push that fails is tried again after an hour, then two, doubling to a day, and raises one evidence_push_failed alert for the month.
Tokens, and getting your values back
What the provider receives in place of a value depends on the class. Card numbers, email addresses, phone numbers and IP addresses are replaced by stand-ins of the same shape — a card number that is a valid card number, an address that is a valid address — so nothing downstream breaks on the shape of it, though printed spacing is not kept: a card written in groups of four comes back as one run of digits. Every other class, IBANs and national identifiers among them, is replaced by a labelled placeholder such asLYNT_PCI_IBAN_… that nothing can mistake for a real value.
What happens next depends on one setting, and it is the difference between an application that works and one that appears to mangle your data.
| Token vault on | Token vault off | |
|---|---|---|
| Your application receives | its own values, restored | tokens, permanently |
| The provider ever holds | tokens only | tokens only |
| We hold | the mappings | nothing |
| Resolvable later | yes, via /v1/detokenize | no, and never |
The middle row is the same either way. The vault decides what we hold, never what the provider holds.
With the vault on the round trip is automatic: the reply is turned back into your values before your application sees it, on streamed and buffered responses alike. Nothing to call, no code to add. A value the model produced that you never sent it is substituted rather than restored, because it is not yours coming home.
With the vault off, substitution is one-way for good. Suitable for “summarise this” and not for “send this to the customer”. A deployment that wants to hold nothing at all should choose it deliberately, not discover it. The receipt names the two differently: with the vault on the decision is tokenize; with it off the same substitution is recorded as redact, because the schema reservestokenize for what can be reversed. The public demo runs with the vault off, which is why its receipts say redact.
Tokens minted while the vault was off never become resolvable by turning it on: no mapping was written, and there is nothing to look up.
Resolving tokens yourself
For content your application stored earlier, or received down a path we never saw. Resolving somebody's data is itself an event, so it produces a receipt naming who asked.
POST /v1/detokenize
{"chain_id": "gw/openai", "content": "Card 9999713846596148 ..."}The chain scopes the lookup exactly as it scoped the substitution: a token issued in one chain does not resolve in another. A response of "resolved": 0 with your content unchanged means these were not tokens this chain issued — usually the wrong chain_id, or tokens minted while the vault was off.
Run it yourself
Self-hosting is the stronger position, not a fallback. Your cloud, your keys, and nothing reaching us — which also removes every data-transfer question from your assessment.
The governing service is a static binary with no shell and no libc, and the root module has no third-party dependencies at all. The analyzer ships its models inside the image and is configured to make no outbound calls, so it runs in an air-gapped network.
Helm and Terraform
helm install lyntway deploy/helm/lyntway \
--set existingSecret=lyntway-secrets # LYNTWAY_SIGNING_KEY, _SIGNING_SEED,
# _TOKEN_KEY, _SESSION_KEY, _POSTGRES_DSNThe chart runs the service — and the analyzer, if you turn it on — as a non-root user, with readiness on /readyz and liveness on /healthz. The five secrets are read from a Secret you already have rather than written into values, so the chart never holds a key. deploy/terraform/lyntway-k8s is a module that installs the same chart on any cluster, with the secrets sourced from AWS KMS, Azure Key Vault or Google Secret Manager behind a flag each, or passed as plain variables when your pipeline already has them.
Where the signing seed comes from
Self-hosted, the master seed is yours: set LYNTWAY_SIGNING_SEED from wherever you keep secrets and it goes nowhere else. On deployments configured for it, the seed is fetched at start from Key Vault by the app's managed identity and is not in the app's configuration: LYNTWAY_SIGNING_SEED_KEYVAULT names the secret, the service logs the vault it read from and never the value, and if the vault cannot be reached the service refuses to start rather than fall back to anything. The seed is in the process's memory while it runs; signing is not performed inside a hardware module.
Limits worth knowing before you rely on it
- No SOC 2 report and no independent penetration test. If your assessment needs either, self-host.
- Single region, and no service level unless separately agreed.
- No password reset by email. Recovery codes are issued once at signup and spent at /recover — keep them, because an account recoverable by whoever controls a mailbox is an account whose evidence is only as good as that mailbox.
- AWS Bedrock and Google Vertex cannot be gatewayed — they sign every request in a way a proxy in the path breaks. Run them through LiteLLM with our callback instead; see below.
Pricing · Testing brief · Terms · Privacy · Data processing · Verify a receipt