STRAJA

Technical integration overview

Straja is an API that screens a crypto checkout session for behavioral signs of real-time coaching — a scammer directing a buyer while they buy — as a layer alongside address screening. This document specifies the integration contract: authentication, the signal vector, every endpoint, errors, latency, the browser SDK, the audit record, and how data is handled.

Base URL
https://api.straja.lumosstudio.ro
Version
v1
Format
JSON over HTTPS
SDK
https://api.straja.lumosstudio.ro/sdk/v1/straja.js

Architecture

Where it sits in a checkout

One verdict call per checkout session, made by your server at the moment the user confirms. Only sessions that come back INTERVENE go further, into a four-question probe that runs from the browser.

01 — BROWSER
The SDK measures three behavioral signals while the user checks out.
→
02 — YOUR SERVER
Adds the account-side signals and calls POST /v1/verdict with a secret key.
→
03 — BROWSER
Shows the screens for the tier, via the SDK or your own UI.
→
04 — RECORD
Probe, pattern and outcome are appended to one decision record.
PASS

No additional screen. About 98% of sessions in calibration.

SOFT

One context screen; the user acknowledges and continues.

INTERVENE

Four-question probe, a pattern screen, then a cancellable 24-hour hold. Never a hard block.

Integration

Two ways to integrate

Both paths use the same server-side verdict call. They differ only in who renders the screens.

With the SDK (recommended)

straja.js collects the three browser-side signals and renders the SOFT, probe, pattern and hold screens as an overlay in a shadow root, so your page styles never leak in or out. It needs only the verdict's per-decision client_secret, never a secret key.

checkout page
<script src="https://api.straja.lumosstudio.ro/sdk/v1/straja.js"></script>
<script>
  // As early as possible on the checkout page
  const straja = Straja.collect({ addressInput: "#destination-address" });

  async function onConfirm(order) {
    // Your server calls POST /v1/verdict and returns its JSON unchanged
    const verdict = await fetch("/checkout/straja-verdict", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ orderId: order.id, behavior: straja.signals() }),
    }).then((r) => r.json());

    const result = await Straja.present(verdict, {
      transfer: "€4,800 in BTC",            // used in the hold screen's copy
      theme: { accent: "#0F62FE", fontFamily: "Inter, sans-serif" },
    });
    // result.action: "proceed" | "cancel" | "hold" | "error"
  }
</script>
your server (Node)
const res = await fetch("https://api.straja.lumosstudio.ro/v1/verdict", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.STRAJA_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    reference: order.id,                     // your own id, returned in the record
    signals: { ...behavior, ...accountSignals(order) },
  }),
  signal: AbortSignal.timeout(20_000),       // see Latency & failures
});
result.actionWhat to do
proceedSend the funds. Returned for PASS, and for SOFT once the user acknowledges.
cancelAbandon the transfer. The SDK has already recorded cancelled on the decision.
holdHold the transfer for delay_hours, then send it unless the user cancels in the meantime. proceed_after_hold: true means the user explicitly confirmed; the SDK has recorded proceeded_after_delay.
errorThe probe could not complete after the user's retry. Apply your fallback policy (see Latency & failures).

Preview both halves of the SDK at api.straja.lumosstudio.ro/sdk/v1/playground.html: it shows the collected signals live, and renders the screens for any test-mode verdict you paste into it.

SDK reference

CallReturns / behavior
Straja.collect({ addressInput })Starts measuring. addressInput is a CSS selector or element for the destination-address field; it may mount later. Returns a collector.
collector.signals(){ idle_gaps_over_60s, tab_switches, address_entry_method } — merge into the verdict's signals on your server.
collector.setAddressEntryMethod(m)Set "qr" (or "pasted"/"typed") for entry paths the SDK cannot observe, such as a QR scanner.
collector.stop()Removes all listeners.
Straja.present(verdict, options)Shows the screens for the verdict and resolves with { action, tier, decision_id } (plus delay_hours, proceed_after_hold for holds, error for errors). Needs the verdict's tier, decision_id, client_secret and reasoning. PASS resolves immediately without showing anything.

present options: transfer (text for the hold screen, e.g. "€4,800 in BTC"), apiBase (defaults to the SDK's own origin), and theme with any of accent, accentText, accentSoft, background, text, muted, surface, border, radius, fontFamily (CSS values), and copy to replace any on-screen text, e.g. to translate the screens: softTitle, softBody, softContinue, softBack, probeTitle, answerLabel, decline, send, readingBack, retry, close, continueLabel, holdTitle, holdBody, holdCancel, holdConfirm, transferFallback ({hours} and {transfer} are filled in). Probe questions and the pattern screen come from the API in English. Screens render in a shadow root and meet keyboard and screen-reader basics: focus moves into the dialog and returns afterwards.

With your own UI

  1. Measure three browser-side signals — gaps of over 60 seconds with no interaction, tab or window switches, and whether the destination address was pasted, typed or scanned — or use Straja.collect() for just this part.
  2. Call POST /v1/verdict from your server when the user confirms amount and destination.
  3. Branch on tier. PASS: continue. SOFT: show one acknowledgment screen, then continue. INTERVENE: start the probe.
  4. Run the probe: call POST /v1/probe once per question, passing the transcript so far, until four questions are answered. These calls can come from the browser with the verdict's client_secret.
  5. Call POST /v1/pattern with the four answers and render the returned screen, then a hold of action.delay_hours with a cancel option.
  6. Report what the user did with POST /v1/outcome.

Authentication

Keys and client secrets

Every request except GET /v1 carries a credential in the Authorization header: Authorization: Bearer <credential>.

CredentialWhere it livesWhat it can do
sk_live_…Your servers onlyEvery endpoint. Verdicts, probe questions and pattern screens come from the model.
sk_test_…Your servers onlyEvery endpoint, in test mode: deterministic, no model call, not billed. Test and live records are kept apart.
cs_…Returned by each verdict; safe to pass to a browserOnly /v1/probe, /v1/pattern and /v1/outcome, only for the decision it was issued with, for one hour.

Keys are issued by Straja per integration and can be rotated or revoked at any time; revoking a key also invalidates the client secrets issued under it. Only a SHA-256 hash of each key is stored. A secret key must never appear in a browser or mobile app.

Data contract

The signal vector

All fifteen fields are required. Two accept null where the value does not exist; a request missing any field is rejected with 400 naming it. Every value is computed by your systems or the SDK — none is inferred by the model.

FieldTypeMeaning / source
idle_gaps_over_60sint ≥ 0Stretches of over 60 s with no interaction during checkout. SDK
tab_switchesint ≥ 0Times the user left the page for another tab or window. SDK
address_entry_method"pasted" | "typed" | "qr"How the destination address was entered. SDK (QR set via setAddressEntryMethod("qr"))
account_age_daysint ≥ 0Age of the account.
is_first_purchasebooleanWhether this is the account's first purchase.
amount_eurnumber > 0This transaction, in EUR.
amount_vs_mediannumber | nullThis amount as a multiple of the account's median purchase. null on a first purchase.
funding_method"card" | "bank_transfer" | "apple_pay"How the purchase is funded.
minutes_to_outboundint ≥ 0Minutes between the deposit clearing and the outbound send.
destination_first_seen_daysint ≥ 0How new the receiving address is to this account's history, in days.
destination_type"fresh_eoa" | "known_exchange" | "contract" | "self_custody"Classification of the destination, from your chain-analysis tooling.
declined_attemptsint ≥ 0Failed payment attempts before this one.
deposit_escalationnumber[]The account's recent deposit amounts in order, ending with this one, e.g. [200, 1000, 4800].
kyc_retriesint ≥ 0Identity-verification attempts on this account.
merchant_verifiedboolean | nullWhether a named recipient is a verified merchant. null when the destination is not a merchant.

API reference

Endpoints

EndpointCredentialPurpose
POST /v1/verdictsk_Score a session. Creates a decision.
POST /v1/probesk_ or cs_Next probe question for an INTERVENE decision.
POST /v1/patternsk_ or cs_The pattern screen, after four answers.
POST /v1/outcomesk_ or cs_Record what the user did.
GET /v1/decisions/{id}sk_One decision record.
DELETE /v1/decisions/{id}sk_Permanently delete one decision record.
POST /v1/labelssk_Report confirmed fraud or legitimate outcomes.
GET /v1/readoutsk_Tier distribution and accuracy against your labels.
GET /v1/decisionssk_List decision records, newest first.
GET · PATCH /v1/settingssk_Threshold profile and retention window.
GET /v1noneHealth check.

Every authenticated response carries a Straja-Request-Id header. Quote it when reporting a problem.

POST /v1/verdict

Optional fields: reference (your own id, up to 200 characters, returned in the record and filterable) and shadow (true when you score a session without acting on the tier). Each call creates a new decision; retrying a failed call is safe.

request
{
  "reference": "order_8f2c41",
  "shadow": false,
  "signals": {
    "idle_gaps_over_60s": 4,
    "tab_switches": 3,
    "address_entry_method": "pasted",
    "account_age_days": 412,
    "is_first_purchase": false,
    "amount_eur": 4800,
    "amount_vs_median": 9.6,
    "funding_method": "card",
    "minutes_to_outbound": 3,
    "destination_first_seen_days": 2,
    "destination_type": "fresh_eoa",
    "declined_attempts": 0,
    "deposit_escalation": [200, 1000, 4800],
    "kyc_retries": 0,
    "merchant_verified": null
  }
}
200 response
{
  "object": "verdict",
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "livemode": true,
  "shadow": false,
  "reference": "order_8f2c41",
  "tier": "INTERVENE",
  "probe_required": true,
  "key_signals": [
    "idle_gaps + pasted address"
  ],
  "reasoning": "Coached-session pattern: …",
  "client_secret": "cs_1790…",
  "latency_ms": 6120,
  "created_at": "2026-09-29T10:41:22.104Z"
}

reasoning is written for a reviewer. The SDK shows it on the SOFT screen; if you build your own UI, decide whether to show it or your own copy.

POST /v1/probe

INTERVENE decisions only. Send the questions asked and answers given so far; an empty transcript returns question 1. At most three turns may be sent (the fourth answer goes to /v1/pattern). Answers up to 1,000 characters. Closed once the pattern screen exists.

request
{
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "transcript": [
    {
      "question": "How did you first hear about this opportunity?",
      "answer": "Someone from the exchange called me."
    }
  ]
}
200 response
{
  "object": "probe_turn",
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "question_number": 2,
  "question": "Where did you get the wallet address you're sending to?",
  "is_final": false
}

POST /v1/pattern

Send exactly four turns. Idempotent: repeating the call returns the screen already generated for this decision.

request
{
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "transcript": [ /* all four turns */ ]
}
200 response
{
  "object": "pattern",
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "headline": "…",
  "observations": [
    { "signal": "…", "user_quote": "…" }
  ],
  "the_pattern": "…",
  "one_thing_to_check": "…",
  "action": { "delay_hours": 24, "cancellable": true }
}

POST /v1/outcome

outcome is one of cancelled, proceeded_after_delay, no_response. May be sent again to correct it; the latest value is kept.

request
{
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "outcome": "cancelled"
}
200 response
{
  "object": "outcome",
  "decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
  "outcome": "cancelled",
  "outcome_reported_at": "2026-09-29T10:44:03.511Z"
}

GET /v1/decisions

Query parameters: limit (1–100, default 25), created_before (ISO 8601; pass the last record's created_at to page), reference. Returns { "object": "list", "data": [ …records ], "has_more": false }. GET /v1/decisions/{id} returns one record; see Audit trail for its fields.

GET · PATCH /v1/settings

Read, or change either field: { "threshold_profile": "conservative", "retention_days": 365 }. The profile applies from the next verdict. retention_days (30–3650, or null to keep records indefinitely) deletes records older than the window in a daily run at 03:00 UTC. Both apply to the partner's live and test keys.

DELETE /v1/decisions/{id}

Permanently deletes one record, for example on a data-subject request. The decision's client secret stops working with it. Returns { "object": "decision", "decision_id": "…", "deleted": true }.

Errors

One error shape

4xx / 5xx body
{
  "error": {
    "type": "invalid_request_error",
    "message": "signals.tab_switches: Invalid input: expected number, received undefined",
    "param": "signals.tab_switches",
    "request_id": "req_3f9a0c1d2b4e6f70"
  }
}
StatustypeMeaningRetry?
400invalid_request_errorBody failed validation; param names the field.No — fix the request
401authentication_errorMissing, invalid, revoked or expired credential.No
403permission_errorCredential not allowed on this endpoint or decision.No
404not_found_errorNo such decision for this key.No
409conflict_errorProbe on a non-INTERVENE decision, or after the pattern screen.No
413invalid_request_errorBody over 64 KB.No
429rate_limit_errorPer-key limit exceeded.Yes, after Retry-After
502model_unavailable_errorThe model declined to complete the request.Once
503model_unavailable_errorModel overloaded after automatic failover.Yes, after Retry-After
500api_errorError on Straja's side.Yes, with backoff

Rate limits are per key and per minute: 300 for live keys and 60 for test keys by default, adjustable per integration.

Latency & failures

Plan for seconds, not milliseconds

Every live verdict is a model call. Measured over 525 recorded verdicts:

6.0 s
median verdict
11.3 s
95th percentile
19.0 s
99th percentile
34.3 s
slowest observed

Shadow readout

Measure accuracy against your own outcomes

Once you know how a session really ended — a chargeback, a scam report, an investigation — report it. Label by your reference or by decision_id, up to 1,000 per call; a label can be sent again to correct it.

POST /v1/labels
{
  "labels": [
    { "reference": "order_8f2c41", "fraud": true },
    { "reference": "order_91aa07", "fraud": false },
    { "decision_id": "dec_9b1f…", "fraud": false }
  ]
}
200 response
{
  "object": "label_result",
  "received": 3,
  "decisions_updated": 3,
  "unmatched": []
}

GET /v1/readout then reports, for any window: how sessions split across tiers, how many labeled fraud sessions were intervened on or soft-flagged (recall), and how many legitimate sessions were (false-positive rate). Parameters: created_after, created_before (ISO 8601), shadow=true|false, and unlabeled=exclude|legitimate — use legitimate when you only label confirmed fraud, so every other session counts as clean.

GET /v1/readout?shadow=true&unlabeled=legitimate
{
  "object": "readout",
  "sessions": { "total": 41200, "pass": 40310, "soft": 512, "intervene": 378, "intervene_rate": 0.0092 },
  "labels": { "fraud": 64, "legitimate": 0, "unlabeled": 41136 },
  "fraud_detection": {
    "fraud_sessions": 64, "intervened": 51, "soft_flagged": 7, "passed": 6,
    "intervene_recall": 0.7969, "any_flag_recall": 0.9063
  },
  "false_positives": {
    "legitimate_sessions": 41136, "intervened": 327, "soft_flagged": 505,
    "intervene_rate": 0.0079, "soft_rate": 0.0123
  },
  "latency_ms": { "mean": 6010, "p95": 11200 }
}

Illustrative numbers. Rates are null when their denominator is zero.

Test mode

Build the whole integration for free

Requests with an sk_test_ key never reach the model. Verdicts follow a fixed rule: one point each for idle gaps ≥ 2, tab switches ≥ 2, a pasted address, a fresh_eoa destination first seen ≤ 7 days ago, and a last deposit ≥ 3× the first. Three or more points gives INTERVENE, two gives SOFT, fewer gives PASS.

To force a tier, send the header Straja-Test-Tier: PASS, SOFT or INTERVENE. Probe questions are the four fixed topics, and the pattern screen is fixed copy that quotes the test answers. Responses carry "livemode": false. Test records are stored and listed separately from live ones.

Compliance

Every verdict is an audit record

One record per decision, stored when the verdict is returned and appended to as the probe, pattern and outcome happen. Retrieve it with GET /v1/decisions/{id} or list by reference.

FieldTypeContent
decision_id, created_atstringIdentifier and timestamp.
livemode, shadowbooleanLive or test key; whether the tier was acted on.
referencestring | nullYour own identifier.
tier, threshold_profilestringThe final tier and the profile applied to it.
signalsobjectThe exact vector the decision was made from.
key_signals, reasoningstring[], stringWhat drove the tier, in plain language.
model, prompt_fingerprintstringThe model that answered and the version of the instructions it was given.
latency_msnumberTime to verdict.
probe_transcriptarrayQuestions asked and answers given.
patternobject | nullThe pattern screen exactly as shown.
user_outcome, outcome_reported_atstring | nullWhat the user did, and when it was reported.

Configurability

Threshold profiles

A deterministic step applied after the model, set per partner with PATCH /v1/settings.

ProfileEffect
balancedThe model's own tier, unchanged. The default, and the profile the stability suite runs against.
conservativeA first purchase of €1,000 or more is at least SOFT; SOFT becomes INTERVENE for a fresh_eoa destination or any declined attempt.
permissiveSOFT under €500 with no declined attempts becomes PASS. INTERVENE is never lowered.

Data handling

What is sent, where it goes

Model

Model & reliability

Verdicts use Claude Opus 5, chosen because the hard cases — two sessions near-identical on paper, differing only in behavior — need reasoning depth. Under sustained overload, calls fail over automatically to Claude Sonnet 5, a separate capacity pool. Every model response is validated against a fixed schema; a malformed response is rejected, not passed on.

75/75
stability runs with no verdict change: 15 calibrated scenarios, five runs each.
~2%
of sessions reach the full probe and pattern path.
$0.044
weighted model cost per screened session, at list pricing.
24 h
maximum hold. Always cancellable, never a block.

Limitations

Current limitations

Getting started

Integration timeline

Integration runs in shadow mode before any user sees a Straja screen, so verdict accuracy is measured against your own outcomes first.

First call

With a test key, this returns a verdict immediately and costs nothing. Remove the Straja-Test-Tier header to see the test rules score the vector instead.

terminal
curl https://api.straja.lumosstudio.ro/v1/verdict \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Straja-Test-Tier: INTERVENE" \
  -d '{
    "reference": "first-test",
    "signals": {
      "idle_gaps_over_60s": 4, "tab_switches": 3, "address_entry_method": "pasted",
      "account_age_days": 412, "is_first_purchase": false, "amount_eur": 4800,
      "amount_vs_median": 9.6, "funding_method": "card", "minutes_to_outbound": 3,
      "destination_first_seen_days": 2, "destination_type": "fresh_eoa",
      "declined_attempts": 0, "deposit_escalation": [200, 1000, 4800],
      "kyc_retries": 0, "merchant_verified": null
    }
  }'

Paste the response into the SDK playground to walk through the probe as a user would.

STEP 1

A test key. Build the server call and the screens against test mode — free, with forced tiers.

STEP 2

A live key on one payment corridor, sending "shadow": true and not acting on the tier. Typically four weeks.

STEP 3

Readout: report confirmed outcomes with POST /v1/labels and read recall and false-positive rates from GET /v1/readout?shadow=true.

STEP 4

Go live on that corridor: act on the tier and present the screens.