Disclosure

What we store, and how to check it

Your prompts and responses stay between you and the provider you linked. What megaloop does keep 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 two callers, both inside the serving path: the request being forwarded, and the token refresh that keeps an account alive. No API route returns a credential in any form.

packages/gateway/src/handler.ts. The only two call sites of openCredential

What a served request leaves behind

One row of metadata per request, so you can see which account served what and when one drops out of the pool. These are all of its columns:

id
the row's own identifier
tenant_id
your megaloop account — every read of the table is scoped to it
created_at
when the request finished
account_id
which of your linked accounts served it
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 or failed
latency_ms
how long it took, end to end
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 — the one column still left null, see below

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 the one column the serving path leaves null today. 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.

The table has no column for a request body, a response body, a header, or an IP address. Client IPs are absent deliberately: there is no trusted source for one behind a proxy, and a forensic field filled with a spoofable header is worse than no field.

packages/db/src/schema.ts request_events

Your prompts and responses stay yours

The response is handed back to you as the provider's own stream. Every chunk is passed along by reference the moment it arrives — not copied, not teed, not queued behind anything we do, and never waiting on a disk, a network, a lock or the next chunk. What reaches you is byte-identical to what the provider sent, in the same order. Only hop-by-hop headers are stripped.

Two bounded windows of it are held 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 of the body. Nothing accumulates beyond that: earlier chunks are released as soon as what remains still covers the window, so an hour-long generation holds no more of itself than the last few seconds of one. What is held is a reference to chunks the provider already sent and you already hold, never a growing copy. Each window 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. No part of the body is written to the database, to a log, or to disk.

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 once, in memory, to find which model you asked for. It is not written to the database or to a log, and nothing persists it.

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 cannot log one even by mistake: its type only accepts strings, numbers and booleans as named fields, so a prompt, a completion or a header value is not expressible as an argument. What it writes is an event name plus values like an account id, an HTTP status, a retry count and a token count.

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, and — if the account has been taken out of the pool — when, 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, so a database read 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.

Two caveats. 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. And there is no self-serve delete-everything button today: deleting a tenant would cascade to every row it owns, but nothing in the product exposes it. Ask us and a human runs it.

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, and it returns aggregates only — counts, timestamps and percentiles. The code behind it has no function that can return a row, so there is no path from it to a prompt, a completion, a credential, a key or an address. Access is a short list of addresses in the server's configuration, and every view is written to the audit log.

packages/control-plane/src/admin.ts · docs/legal/privacy.md. Retention, sub-processors and key handling in full