Disclosure

What we store, and how to check it

We keep your requests and responses only if you switch it on, encrypted for seven days, then delete them; the metadata beside them for ninety. Everything megaloop holds is named here as the column it actually is, with the file that proves it, written from the code as it is today.

Your provider credentials

Encrypted at rest with AES-256-GCM. There is no plaintext token column in the database. The only token columns that exist hold ciphertext. Each secret carries its own nonce inside its own envelope, so no two secrets can share one.

Each ciphertext is bound to the row and the tenant it belongs to: the authenticated data is your tenant id and the account's own id. A credential copied into another tenant's row fails to decrypt rather than quietly working.

The key is read from the environment and never generated. Reading the database is not enough to read your credentials.

packages/core/src/crypto.ts · provider_accounts.access_token_ct

They are decrypted only to serve a request

Opening a credential is a separate call from listing accounts, so a listing cannot leak one. That call has exactly three callers. Two are inside the serving path: the request being forwarded, and the token refresh that keeps an account alive. The third is the background job that asks a provider how much of your subscription is left, for providers that only answer that when asked — it runs on a timer and sends nothing of yours. No API route returns a credential in any form.

packages/gateway/src/handler.ts and packages/notifications/src/quota-refresh.ts. The only three call sites of openCredential

What a request leaves behind

One row of metadata per attempt on an account, so you can see which account served what and when one drops out of the pool. A request we turn away before reaching any account — an expired subscription, a key that may not serve inference, a body we could not read — leaves one too, with the provider and model columns empty because there was never one to name. These are all of its columns:

id
the row's own identifier
tenant_id
your megaloop account, and every read of the table is scoped to it
created_at
when the request finished
account_id
which of your linked accounts served it
api_key_id
which of your own API keys made the call, so you can tell your apps apart
pool_id
which pool the key was limited to, if any — empty when it could use every account
request_id
which of your requests this attempt belonged to — one request that tried several accounts writes a row each, and this is what makes them one request again
provider
which provider that account belongs to
model
the model name you asked for
path
the endpoint path, e.g. /v1/messages
status_code
the HTTP status the provider returned
response_class
whether it served, rotated, paused, failed, or was refused before reaching an account
upstream_reason
when a request was refused, the provider's own one-line reason, truncated — never your request or its content
latency_ms
how long until the response started
first_token_ms
how long until the first generated token arrived, on a streamed response
completed_ms
how long the whole request took, once the response has finished
overhead_ms
how much of that was us rather than the provider
input_tokens
prompt tokens it reported, apart from the two cache figures
output_tokens
the tokens it generated
cache_read_tokens
prompt tokens it served from its own cache
cache_write_tokens
prompt tokens it wrote into that cache
resolved_model
the model it says it actually served, left null on this path, see below
client_ip
the address the request came from, as our CDN reported it, never from a header you set
user_agent
the user-agent your client sent, truncated

Four of those columns are token counts, and they are filled in. They are the figures the provider itself reports about its own response, not a count we take of your text, and we keep them so your dashboard can show what the same traffic would have cost at that provider's published list price. What is read to get them is the next section, in full.

A count is not content: four integers say how much was sent and generated, and nothing about what it said. resolved_model is left null on this path: the id that would go in it arrives in the first frame rather than the last, and pricing does not need it — both the alias and the dated id map to the same rate. Three more are empty on a request we refused before choosing an account: provider, model and account_id, because none had been decided.

This table has no column for a request body or a response body. The bodies live in one separate table, encrypted, for seven days — see Prompts and responses below. Two headers are kept here and nowhere else: the address your request came from and your client’s user-agent, both added 2026-08-19 and both deleted with the row at ninety days. The address is taken from our CDN’s own reading of the connection and never from a forwarding header your client can set — a forensic field filled with a spoofable value is worse than no field.

packages/db/src/schema.ts, request_events

Your prompts and responses, and the seven days we keep them if you ask us to

We keep nothing of what you send unless you switch it on, and a new account starts with it off. With it on, a copy of each request and response is kept for seven days, encrypted, and then deleted permanently — so that a request that failed can be diagnosed from what was actually sent, which a status code alone often cannot tell apart from a request for something that does not exist. Credentials are stripped before anything is written, so a key or token in what you sent is stored as [redacted:…]. Nothing in the dashboard displays it. One API returns it, to an admin of your account — not a member, and not an API key — and every such read is recorded in your audit log. Otherwise it is readable by us, on the server, with the key, and by nothing else. Switching it off in Settings → Privacy deletes every copy already stored, immediately.

The response is still handed back to you as the provider's own stream, and the capture never sits between the provider and you: each chunk is delivered to you first and copied afterwards, so the copy costs us memory and costs you nothing. What reaches you is byte-identical to what the provider sent, in the same order. Only hop-by-hop headers are stripped. The copy stops at 1 MiB and the stored row records that it stopped, rather than pretending to be whole.

What is kept is encrypted with AES-256-GCM before it is written — the same protection your provider credentials get — in one table used for nothing else, bound cryptographically to your account and to that single request. A copy of our database, without the key, is ciphertext. No header you send is stored here, and nothing is written to a log: our logger is never handed request or response content. Two headers are kept on the metadata row instead — see What we keep instead. None of it is used to train anything, sold, shared, or read automatically.

Two bounded windows of the body are also read in memory as it passes, and they exist for the counts above: the first 4,096 bytes of the first chunk, and a rolling last 16,384 bytes. Each is scanned for exactly one thing: the usage figures the provider states about its own response. The tail is where the final figures sit, and it fills the columns above; it is scanned only after the stream has closed and you already hold every byte. The head carries an early cache-hit count, which goes to our own operational log and no further.

The window is kept only where a provider publishes those figures and we have a reader for them. The self-hosted-endpoint archetype has none, so for it nothing is read at all.

Your request body is read to find which model you asked for, and stored under the seven-day capture above. It is never written to a log.

It is modified before it is forwarded, and this is all of it. An account-derived identifier is filled into the body's metadata if you sent none of your own. On one provider's path a fixed one-line marker is prepended to your system prompt. your own prompt is kept, in full, immediately after it. Nothing you wrote is deleted, edited or moved elsewhere in the request, and a body that already carries that line is forwarded exactly as it arrived. If you sent no system prompt at all, that line becomes the whole of one. The marker is there because that provider refuses its premium models on a subscription credential whose system prompt does not begin with it.

Headers are rewritten on the way out too. Most of yours pass through untouched; your megaloop credential is swapped for the account's own, a session id derived from that account is always set, and the platform block a first-party client sends is filled in when your client sends none. On one provider's path that client identity is always ours rather than yours: your user-agent and originator are replaced with that provider's own CLI values on every request, because it gates which models an account may reach on the client version it is told. Both providers, both modifications and the reason for each are set out in full in docs/legal/privacy.md.

The gateway's logger takes an event name plus named fields that must each be a string, number or boolean — never an object, so a request or a response body cannot be handed to it whole. What the serving path actually passes it are values like an account id, an HTTP status, a retry count and a token count: no call site anywhere on that path passes a value taken from your prompt or the model’s reply.

When an account is rate-limited and we move to the next one, its unread response is cancelled rather than read.

packages/gateway/src/handler.ts, passThrough, holdSlotUntilDelivered, PEEK_BYTES and TAIL_BYTES · packages/gateway/src/body-marker.ts, ensureClaudeCodeMarker, which re-exports the transform from the provider directory that applies it · packages/gateway/src/logger.ts. The outbound headers are set by each provider's own forward path, under packages/providers/src

What we keep about an account's health

On each linked account we store when it was last used, when a cooldown lifts, and whether that cooldown came from a rate limit or a failure. If the account has been taken out of the pool, we also store when that happened and the short reason why.

We also keep a snapshot of each quota window your provider reports: the window's label, the fraction of it spent, when it resets, and when we read it. That is what the headroom figures on your dashboard are, and what the quota-aware routing strategies rank on. It is a reading about your subscription, not about your work: it says how much of a window is gone, never what you sent.

provider_accounts.cooldown_until · cooldown_kind · paused_at · last_used_at · account_limit_windows

Sign-in without a password to lose

There is no password column. You sign in with a code emailed to you, and what is stored is a keyed hash of that code, single-use and short-lived. Sessions store a hash of the session token; API keys store a hash plus the visible prefix, and — while the key is live — the key itself sealed under the same envelope as your provider credentials, so you can copy it again from its row. Revoking destroys that copy. The envelope key lives in /etc and not in the database, so a database read still yields no usable key. We do keep the browser user-agent string of each session.

Linking an account never asks for your password at the provider. There are three ways an account can be linked, and which ones a provider offers is the provider's business:

  • Browser approval. You approve on the provider's own site and paste back the code it shows you. The verifier that pairs with it is sealed on our side and never returned to any client. We never see the password you use there.
  • Pasted key. You paste a key you already hold. It is sealed the moment it arrives and never rendered back.
  • Self-hosted endpoint. A base URL and an optional token, sealed the same way.

The last two are credentials you hand us on purpose. They get the same encryption as the first, but they are ours to hold until you unlink.

packages/auth/src/codes.ts · packages/control-plane/src/api-keys.ts · link_sessions.verifier_ct

Payment

Card details never reach us. Checkout and plan changes happen on the payment provider's own hosted pages; we redirect you there and back. What we store is the customer and subscription id they give us, the subscription status, the price id, the renewal date, your plan tier and your trial end date.

Those columns are written only from a webhook whose signature we verify first, and the tenant it applies to is resolved from our own stored mapping, never from anything in the request.

tenants.stripe_customer_id and the columns beside it

What you can delete, and how

Unlink an account
Dashboard → the account's row → Unlink. This is a real delete: the row and both ciphertext columns are removed from the table, not flagged. The credential is gone from our side; revoking our access at the provider is a separate thing you do there.
Revoke a key
Dashboard → the Keys panel → Revoke. This stamps the row revoked. The serving path caches a key's identity for up to 60 seconds to keep a database read off every request, so a revoked key can still authenticate for that long. If you are revoking a key because it leaked, treat one minute as the window, not zero. The row itself is kept, on purpose, so an audit can account for every key that ever existed. Only the hash and the prefix were ever stored.
End a session
Sign out. The session is revoked server-side.

One caveat. Unlinking an account does not erase the metadata rows for requests it already served. Those deliberately outlive the account, so the history of what ran stays legible.

Closing the account is self-serve. Settings has a button that cancels any live subscription, then deletes the account and every row it owns: linked accounts and their stored credentials, keys, request history, settings. It is immediate and there is no undo. Security records that carry your address, named in the privacy policy, are the exception.

Who else is in the path

Your prompts go to your own provider. The account that served a request is an account you linked and already pay for, so your work reaches the provider you already chose. No reseller, no broker, no model of ours in the middle.

One company does sit in the path, and it is not an AI service. megaloop.dev is fronted by a Cloudflare Tunnel, which terminates TLS, so every request, inference included, is in plaintext at their edge for as long as it is in flight. There is no way to serve the product on this domain without it.

Two vendors handle the parts of the product that are not inference: an email provider delivers your sign-in codes, and a payment provider handles billing. Each sees only what that job needs: an address, or billing details. Neither is in the path of a request.

Our own operator console cannot reach your work. One internal view reads across accounts. It sees counts, timestamps and percentiles, and it lists tenants: the address you sign in with, your account name, your plan and how many accounts you have linked. It cannot reach a prompt, a completion, a credential, a key, a key prefix, a credential hash or a session, and no query behind it names one — the only hash it can see is the digest of the published documents themselves, which is how it knows which version you accepted. Access is a short list of addresses in the server's configuration, and every view is written to the audit log as an identifying read.

If you write to us through the contact form, that message and the address you gave are stored and shown on the same console. That is what lets a person answer you, and it is kept whether or not you have an account.

packages/db/src/admin-read-model.ts · packages/db/src/support-tickets.ts · docs/legal/privacy.md. Retention, sub-processors and key handling in full