What leaves your systems, what never does, and how access to your organization is controlled.
What reaches Ergoda
The SDK runs in your process and routes there. What it sends to Ergoda is metadata about each request:
- which step it was, which model answered, and which model it was compared with;
- token counts, cost and latency;
- the checks' verdicts and scores;
- SHA-256 hashes of the input and the output, never the text;
- for a tool call, the first 16 characters of the SHA-256 of the tool's name, never the name;
- for a request made on behalf of one of your users, a SHA-256 of their id with your site id, never the id;
- for an error, its type and HTTP status, never its message.
These are real facts about your traffic: which models you call, and what they cost. None of them is content.
A hash is not a lock. Your site id travels with every event, so whoever holds
the events can test a guess against the user-id and tool-name hashes: a common
tool name such as refund_order, or a known email address, is recognisable.
To make them unguessable, set ERGODA_DIGEST_KEY to a secret of your own, the
same in every process that sends events (the TypeScript SDK reads it too, or
takes digestKey). Both hashes then become an HMAC under a key we never see.
Per-user alerts and decision classes keep working, because equal values still
match inside your site. Changing the key starts each decision class's evidence
again.
The SDK and the proxy send only these. Ergoda's servers also check the shape of
every field they receive, so a key that is leaked, or a producer that is
broken, cannot put text into your records: a hash field takes lowercase hex of
at most 64 characters, a name field takes at most 128 characters of letters,
digits and _ . : / @ + # = - with no spaces, and a list field takes at most 16
such names. A value that is not that shape is dropped whole, and the rest of
the event is kept. This is a check of shape, it says nothing about who sent the event: a short token
without spaces still passes as a name.
Requests are bounded too. A request body over 4 MiB, or a batch of more than
1,000 events, is refused with a 413, and a site sending faster than one
process accepts (300 events a second sustained, 6,000 in a burst) gets a 429
with Retry-After. The SDKs split a refused batch and keep a rate-limited one
for the next flush, so neither reaches your application.
What never reaches Ergoda
Prompts, completions, tool arguments, tool results, tool names, error messages, user data and credentials.
The one exception is opt-in. You can submit your agent's tool catalogue for classification, which sends it to us. The submission records who sent it, and withdrawing it deletes it.
Controls on your side
The SDK runs in your process and the proxy on your network, so these are set by you and nothing we send can change them.
- Which models a policy may use. The routing policy, and the allowlist in it, comes from Ergoda. Set
ERGODA_ALLOWED_MODELS(orallowed_models=in Python,allowedModelsin TypeScript) to the models you are willing to send traffic to, asprovider/model, a bare model name, orprovider/*. A policy that names anything else is refused, and that step goes to the model your own code asked for. The proxy applies the same list to the models its callers name, and answers 403 for anything outside it. - Policies you can check. When we sign policies, every policy reply carries an Ed25519 signature over your site id. Pin our public key (from
https://api.ergoda.com/v1/policy-keys, compared with the one on this page) asERGODA_POLICY_PUBLIC_KEY, orpolicy_public_key=/policyPublicKey, and installergoda[verify]in Python. The SDKs and the proxy then refuse any policy that is unsigned, altered, signed for another site or signed by a key you did not pin, and keep the last good one. Every answer also carries a signed time, so an old reply played back is refused, and a policy no signed answer has confirmed for 24 hours stops being served: every step goes to the model your code asked for. Pin two keys during a rotation. Attestations carry the same signature, so an auditor can check one with the public key alone. Our current key (idf08ba0a4a69512df, since 2026-10-02) isQ3uOWiwSy4zFoI6aiN1P4KBc58514kN2SE9/WcjetQQ=; a new one is announced here at least 30 days before it signs anything. - Keys only over HTTPS. The SDKs, the CLI and the proxy refuse any request to a control plane over plain
http://to a host other than this machine: one that sends a key, one whose answer is a key (ergoda login), and a policy fetch with or without a key. They never follow a redirect from the control plane.ERGODA_ALLOW_INSECURE=1lifts the refusal, for a development control plane on a network you trust. - Provider keys stay with the provider. Calls the SDK or proxy makes to a model provider never follow a redirect. Anthropic and Gemini keys travel in headers that an HTTP client would otherwise carry to the redirect's target.
- Recordings.
ergoda.record()writes whole prompts and completions to a local file. It creates the file readable by your user only, and it leaves out every keyword argument that carries a credential (api_key,base_url, headers and the like). - What the SDK reads back. The TypeScript SDK reads at most 4 MiB of a policy reply and 64 KiB of an events reply, and both SDKs reduce an error to its type and status before it is sent.
Keys
- A key is
hk_and 64 more characters. It is shown once, when it is made, and stored only as a hash. - Each key has one scope. An agent key can send events and fetch the routing policy, and nothing else. A read key can only read. A full key can do everything, including changing routes.
- Revoking a key stops it on its next request.
- The evidence a route is licensed on is what your own process reports: the control plane re-reads those rows and cannot re-run your frontier model. Anyone holding an agent key for your site can therefore send rows that count as evidence, so keep agent keys as private as full ones. Rows are not yet attributed to the key that sent them; that is planned.
- An owner can refuse full-access keys for agents (Organization). While refused,
ergoda login --scope fullis refused with the reason, a key cannot make a full key, and refusing them revokes every full key a terminal login or another key made, with what those keys made, in the same transaction. A full key a person makes under API keys, or an operator makes, is not an agent's and stays; while they are refused, only the owner makes a full key under API keys. - A full key can lock a step to your frontier model and cannot unlock one. Only a person signed in to the dashboard can, and two different people when the lock asks for it: the owner, or a member the owner (not another member) added. While a step is locked, no version that routes it is accepted, whoever sends it (
POST /v1/sites/{site}/policy/lock). - A read key sees the host of your alert webhook (
https://hooks.slack.com/…), not its path: a chat webhook's path is its secret. Only a full key and the dashboard's settings page show the whole address. - Every API reply says
Cache-Control: private, no-store, errors included, so a shared cache keeps nothing a key was used to read.
Signing in to the dashboard
- A work email and a one-time code, through WorkOS. A code lasts ten minutes. Each mailbox gets ten codes an hour, and twenty wrong codes an hour from every source together; past that the emailed-code form sends it to WorkOS's own sign-in page for the rest of the hour. A try is counted before WorkOS is asked, so guesses sent in parallel count too. Limits count a mailbox, not a spelling (
me+tag@isme@), and an IPv6 network by its /64. - Passkeys and company single sign-on go through WorkOS's own sign-in page.
- Only a sign-in that shows the person holds the address opens a session: the emailed code, a passkey, company single sign-on, or Google. Any other method (Microsoft, GitHub, Apple, a password) is refused and sent to the emailed code, since another provider's word that an address is verified is only as good as that provider. The rule is checked on every request, not only at sign-in, so a session from a refused method, or a single sign-on whose organization no longer covers the address, stops working at once.
- An owner can require a passkey or the company's single sign-on to open the organization (Organization page). Members who signed in with an emailed code are then refused until they sign in again with one, and the owner can only switch it on from a session that already qualifies.
- Company single sign-on (SAML or OIDC) and directory sync run through WorkOS, which holds the certificates and directory tokens; we hold the WorkOS organization's id, its verified domains and whether its connection is active (The dashboard, Single sign-on). A single sign-on makes a session only for an address at one of that organization's verified domains; any other address it asserts is refused before a session exists, since an identity provider can assert any address. With Require SSO on, an address at a verified domain opens the organization only from a session that signed in through that organization's own connection, checked on every request, so a session that did not is refused at once. A member's address at a verified domain cannot be changed from the Account page, so it stays inside Require SSO and the directory's reach.
- Single sign-on adds a person as a Viewer, or as a Member only when the owner has mapped a directory group to Member and the directory has them in it; never as the owner. A directory that deletes or deactivates someone removes them and revokes the keys they made in one transaction. Directory events arrive as webhooks signed with HMAC-SHA256 over the raw body (
WorkOS-Signature, three minutes of tolerance), are applied once per event id, and act only on addresses at the organization's verified domains. - A session lasts eight hours, in a cookie that only
app.ergoda.comcan read and scripts cannot. Every change the dashboard makes also carries a token in a header, so another website cannot make one on your behalf. - What a person does in the dashboard (an approval, a key, a revocation, a change of member or setting) is recorded with their verified name.
Audit log
Every sign-in, key, member, settings, autopilot and policy change, account
closing, recovery link, single sign-on link and setup link, person added by
single sign-on or removed by your directory, and every request made with our
operators' admin token is written to an append-only log. Each row names who
acted (a verified address, a key id, the admin token, a scheduled job, or
single sign-on and your directory), from which network
(the /24 or /48, never the full address), and what changed, never a secret
value. A change to a key, member, setting or policy and its log row are
written in one transaction, so neither exists without the other. Read or
export your site's log with a read key at GET /v1/sites/{site}/audit.
Rows are kept for 13 months. When an account is erased, its rows go with
it, and the log keeps only the fact that the site was erased.
Separation between organizations
Every request is bound to one organization before it reads anything, and the database backs that binding with row-level security on events, policies, keys and tool catalogues: an endpoint that forgot its check reads one organization's rows or none. That is a second wall behind the first, and does not protect against a compromised server process, which holds the credentials the first wall runs on; billing records (accounts, usage) are not under it, and are reached only through the checks in the code.
Checking an alert
Each alert your webhook receives carries an X-Ergoda-Signature header: the
hex HMAC-SHA256 of the exact request body, keyed with your signing secret. The
secret is shown once, when you save the webhook or ask for a new one. Check the
signature before acting on an alert:
import hashlib
import hmac
def is_from_ergoda(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
A webhook must be an https:// address, and may not point at a private,
loopback or link-local address, however it is spelled: a host that is a bare
number (2130706433, 0x7f000001, 0177.0.0.1, 127.1) is refused, and so is
an IPv6 address that wraps a private IPv4 one (::ffff:127.0.0.1, a 6to4 or
NAT64 form). The name is checked when you save it and resolved again before
every delivery, and a delivery that resolves to a private address is not sent.
Redirects are never followed. One gap remains: the address a name resolves to
is checked and then connected to as a separate step, so a name that answers
with a public address to the check and a private one to the connection (DNS
rebinding) is not stopped by this check. Use a webhook host you control.
Where it runs
The routing decision and the checks run in your process, or in the proxy on your own infrastructure. The control plane, which we host, receives what is listed above and publishes your policy; running your own copy of it is not offered. What your agents do when it is unreachable, or if Ergoda stops, is in concepts under "If Ergoda is unreachable, or goes away".
The companies that process data for us, and where, are on the sub-processors page. The privacy policy and the DPA are the commitments.
Reporting a vulnerability
Email support@ergoda.com with "security" in the subject, and please do not report it in public first. We acknowledge within two business days, and send an assessment and a fix date within ten. We will not take legal action over research that tests only your own account and sites, reads or changes no other customer's data, does not degrade the service for others, and gives us reasonable time to fix the problem before it is disclosed.