Back to Dev Blog
The ONCE Fraud API: A One-Way Mirror Over the Risk Engine
·
securityengineeringapi

The ONCE Fraud API: A One-Way Mirror Over the Risk Engine

How we turned an internal fraud-scoring engine into a public API. The design is mostly about what we refuse to return: no vendors, no IPs, no PII, no internal flag ids.

WedgeWedge

A few months ago we wrote about ONCE Fraud Detection, the daily engine that scores every account on the platform. Internally it is a rich, opinionated system: audio fingerprints from a named vendor, IP and subnet clustering, streaming- and monetization-integrity heuristics tied to specific platforms, a self-tuning calibration model, and a signal that comes straight out of our Note conversations. It knows a lot, and most of what it knows is exactly the kind of thing you never want to leave the building.

So when we decided to expose that engine as a public product, the ONCE Fraud API, the interesting engineering wasn't the scoring. That already existed. The interesting engineering was the boundary. How do you let anyone with an API key ask "is this email risky?" and get a useful, actionable answer, without leaking the vendor names, the IP heuristics, the internal thresholds, the roadmap, or a single byte of anyone else's personal data?

The answer is a one-way mirror. Callers see risk. They never see how we detect it.

The shape of the request

The whole API is one endpoint:

POST https://once.app/v1/fraud/score
Authorization: Bearer once_pat_xxx
Content-Type: application/json

{ "email": "artist@example.com" }

You send an email. You get back a weighted risk score, a severity band, and a list of flags. That's the entire contract. There is no pagination, no account browsing, no "list everyone who tripped this flag." The API answers exactly one question about exactly one email at a time, and it answers it in a shape that is safe to store, log, and act on directly.

The one boundary

Internally the engine carries a codename and a great deal of implementation detail (vendor identities, IP and subnet heuristics, exact thresholds, unshipped roadmap flags), none of which the public surface may reveal. Rather than sprinkle sanitization across the request path, we funnel everything through a single module whose only job is to be that boundary:

┌─────────────────────────┐        ┌──────────────────────────┐
│   Internal engine        │        │     ONCE Fraud API        │
│                          │        │                          │
│  • vendor names          │        │  • public flag codes     │
│  • IP / subnet signals   │──╳────▶│  • neutral labels        │
│  • exact thresholds      │  one   │  • broad categories      │
│  • Note agent signal     │  way   │  • score + severity      │
│  • disabled roadmap flags│  only  │                          │
│  • per-account evidence  │        │  (never anything else)   │
└─────────────────────────┘        └──────────────────────────┘

Every internal flag is re-projected into a neutral, brand-safe public code and label before it can reach the wire. The module that does this enforces a short list of rules:

  • Only enabled flags are exposed. Disabled and roadmap flags never leak, not even as a hint that they exist.
  • No internal flag id ever reaches the wire. Callers see a stable public code that describes the kind of risk, not the detection method behind it.
  • No vendor names. No "IP," "subnet," or "Note" wording. The internal codename appears in no public string anywhere.

So acr_high_match_multiple, an internal id that names the audio-fingerprint vendor's behavior, becomes:

acr_high_match_multiple: {
  code: 'audio_match_multiple',
  label: 'Audio fingerprint matches on multiple tracks',
  category: 'rights',
},

The consumer learns that multiple tracks matched a fingerprint. They do not learn who runs the fingerprint database, what the similarity threshold is, or how many other flags feed the same category. That is deliberate. A public code is a promise about the kind of risk; it is not a window into the machine.

That projection isn't a one-time chore. It's where every new signal lands. A streaming-integrity check we shipped more recently keys off a suspicious spike in one video platform's monetization data on a freshly distributed release. Its internal id spells out the platform and the exact revenue surface it watches; the public projection is just monetized_activity_spike, labeled "Potential manipulated monetized activity," in the streaming category. The caller learns that monetized activity looks manipulated. The platform, the surface, and the threshold behind it never leave the building.

Failing the build instead of leaking

A boundary is only as good as the thing that keeps it honest. The obvious failure mode is drift: someone adds a new internal flag six months from now, forgets the public projection, and the flag either silently vanishes from the API or, far worse, leaks through with its raw internal wording.

We close that gap with a guard that runs at module load:

function assertPublicFlagCoverage(): void {
  const missing = FLAG_DEFINITIONS
    .filter((d) => d.enabled && !PUBLIC_FLAG_MAP[d.id])
    .map((d) => d.id);
  if (missing.length > 0) {
    throw new Error(
      `[fraud-api] Enabled flags missing a public mapping: ${missing.join(', ')}. ` +
        `Add them to PUBLIC_FLAG_MAP (and never expose vendor/IP/internal wording).`,
    );
  }
}

assertPublicFlagCoverage();

Every enabled internal flag must have a public mapping. If one doesn't, the module throws the moment it's imported, which means the build fails in dev and CI, not silently in production. Forgetting to map a flag is impossible to ship. The boundary is enforced by the compiler-adjacent, not by a code reviewer remembering to check.

Why the public score is a different number

Here is a subtlety that surprised us during design. The internal engine doesn't score with static weights. It runs a daily calibration pass that compares each flag's real-world suspension rate against baseline and nudges the weights up or down. The model self-tunes as the fraud landscape shifts. We wrote about that calibration in the detection post.

That's great internally and wrong externally. If the public API used calibrated weights, the same account with the same flags would return a slightly different score every day as the model retuned itself. Worse, the drift would leak information about the calibration model. So the API recomputes the score from the stored flag booleans using the published base weights, not the calibrated ones:

SeverityBase weight
low10
medium25
high50

The result is a stable contract. A given set of flags always produces the same score, capped at a maxScore of 300 and bucketed into none / low / medium / high / critical. The number you get is honest and reproducible, and it doesn't quietly encode anything about how the internal model evolves.

A handful of the most serious signals, a couple of the newer streaming-integrity checks among them, carry a fixed policy weight above their severity default, so a single corroborated hit can move the score decisively. And the base weights themselves do get re-tuned, but deliberately: we recently dropped a volume-only signal a full tier so that sheer upload velocity, with nothing else behind it, can no longer push an account into a high band on its own. Those are versioned, reviewed changes to the published weights, not the day-to-day drift the calibration model would introduce, which is exactly why the public score stays off the calibration path.

Sanitizing at the data layer

The flag projection handles taxonomy. But there's a second place data could escape: the database read itself. The scoring service that turns an email into a result is deliberately paranoid about which columns it even asks for:

const { data: risk } = await supabaseAdmin
  .from('account_risk')
  .select('flags, no_risk, computed_at')   // never evidence, IPs, or PII
  .eq('user_id', profile.id)
  .maybeSingle();

It selects three columns and only three. It never reads the per-account evidence blobs, never touches IP columns, never joins to other accounts. You cannot echo what you never fetched.

Two edge cases matter here, and both resolve toward less disclosure:

  • Cleared accounts. An account an admin has marked "no risk" returns a clean result (score: 0, severity: "none", flags: []) regardless of any stale flags still sitting in the row. The override itself is never revealed; from the outside a cleared account is simply indistinguishable from a clean one.
  • Never-scored accounts. An account the engine hasn't evaluated yet also returns a clean, all-clear result, with computedAt: null so you can tell "scored and clean" from "not yet scored."

Everything else is an honest error: an invalid email is a 400, and an email with no ONCE account behind it is a 404. We match emails literally (wildcards are escaped before the lookup), so no one can probe the account table with pattern matching.

Auth is two locks, not one

The Fraud API is API-key only. It does not accept the OAuth tokens the rest of our REST surface uses, and it requires a scope that developers have to opt into explicitly. The handler checks both, in order:

// 1) Must be an API key (Personal Access Token), not an OAuth token.
if (!isPersonalAccessToken(accessToken)) {
  throw permissionError(
    'The ONCE Fraud API requires an API key…',
    'fraud_api_key_required',
  );
}

// 2) That key must carry the once:fraud scope.
const scopes = await getPersonalAccessTokenScopes(accessToken);
if (!scopes || !scopes.includes(FRAUD_API_SCOPE)) {
  throw permissionError(
    'This API key is not authorized for the ONCE Fraud API…',
    'fraud_api_scope_required',
  );
}

Two locks means a key minted for ordinary distribution work can't reach fraud scoring by accident. A developer has to go into the dev center, create a key, and deliberately turn on the once:fraud scope. Everything else gets a 403 with a machine-readable code (fraud_api_key_required or fraud_api_scope_required) so you know exactly which lock you hit.

Free-tier limits that fail open

During the public beta the API is free, which means the rate limits exist to stop abuse of a leaked key, not to meter billing. There are two, both keyed per account, both overridable by env var so we can tune them without a deploy:

WindowLimit
per minute60 requests
per day1,000 requests

These sit behind the global limit that already guards every API-key request, so the fraud endpoint is defense-in-depth by default. One design choice worth calling out: when the rate-limiter backend itself is unavailable (the limiter's infrastructure, not a real quota breach), the endpoint fails open. A limiter outage should degrade to "we couldn't count this request," not "the API is down." But a genuine quota breach always surfaces a real 429 with a Retry-After. We fail open on our own infrastructure problems and closed on actual abuse.

Watching it in production

Fraud API calls get their own log source. Instead of burying them in the firehose of generic API traffic, every scoring request is tagged fraud_api in our ops event stream, so an admin can isolate exactly who is scoring what, how often, and with what outcome. It's the same principle as the rest of the design: the more sensitive the surface, the more legible its usage should be to us, and the more opaque its internals should be to everyone else.

What's next

The boundary is built to grow without widening:

  • More flags, same contract. As the internal engine adds detection signals, each one that ships gets a neutral public projection, and the build guard guarantees we can't forget. The streaming-integrity area picked up new checks just recently (potential manipulated monetized activity, plus a marker for accounts already actioned for streaming abuse), and both flowed straight to the wire as new public codes without widening what the API reveals. The API's vocabulary expands; its guarantees don't change.
  • Beyond email lookups. Today you score one email at a time. We're looking at a batch shape for teams checking many accounts at once, with the same sanitization applied per result.
  • Webhooks for risk changes. Rather than re-polling an email, subscribe once and get notified when an account's score or severity crosses a band. The hard part isn't the delivery (we already run webhooks elsewhere); it's deciding which transitions are worth a push without leaking timing signals about the engine.

Exposing a fraud engine to the public turned out to be mostly a problem of subtraction. The scoring was already there. The work was drawing one clean line, enforcing it at build time, and making sure that everything on the wrong side of that line (vendors, IPs, evidence, PII, the roadmap, the codename) stays exactly where it belongs. What's left is a number, a severity, and a handful of neutral flags. That's all a caller needs, and it's all they'll ever get.

The ONCE Fraud API is free during the public beta. Create a key with the once:fraud scope and score your first account in minutes.