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.
https://api.straja.lumosstudio.rov1https://api.straja.lumosstudio.ro/sdk/v1/straja.jsArchitecture
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.
POST /v1/verdict with a secret key.No additional screen. About 98% of sessions in calibration.
One context screen; the user acknowledges and continues.
Four-question probe, a pattern screen, then a cancellable 24-hour hold. Never a hard block.
Integration
Both paths use the same server-side verdict call. They differ only in who renders the screens.
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.
<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>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.action | What to do |
|---|---|
| proceed | Send the funds. Returned for PASS, and for SOFT once the user acknowledges. |
| cancel | Abandon the transfer. The SDK has already recorded cancelled on the decision. |
| hold | Hold 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. |
| error | The 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.
| Call | Returns / 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.
Straja.collect() for just this part.POST /v1/verdict from your server when the user confirms amount and destination.tier. PASS: continue. SOFT: show one acknowledgment screen, then continue. INTERVENE: start the probe.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.POST /v1/pattern with the four answers and render the returned screen, then a hold of action.delay_hours with a cancel option.POST /v1/outcome.Authentication
Every request except GET /v1 carries a credential in the Authorization header: Authorization: Bearer <credential>.
| Credential | Where it lives | What it can do |
|---|---|---|
| sk_live_… | Your servers only | Every endpoint. Verdicts, probe questions and pattern screens come from the model. |
| sk_test_… | Your servers only | Every 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 browser | Only /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
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.
| Field | Type | Meaning / source |
|---|---|---|
| idle_gaps_over_60s | int ≥ 0 | Stretches of over 60 s with no interaction during checkout. SDK |
| tab_switches | int ≥ 0 | Times 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_days | int ≥ 0 | Age of the account. |
| is_first_purchase | boolean | Whether this is the account's first purchase. |
| amount_eur | number > 0 | This transaction, in EUR. |
| amount_vs_median | number | null | This 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_outbound | int ≥ 0 | Minutes between the deposit clearing and the outbound send. |
| destination_first_seen_days | int ≥ 0 | How 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_attempts | int ≥ 0 | Failed payment attempts before this one. |
| deposit_escalation | number[] | The account's recent deposit amounts in order, ending with this one, e.g. [200, 1000, 4800]. |
| kyc_retries | int ≥ 0 | Identity-verification attempts on this account. |
| merchant_verified | boolean | null | Whether a named recipient is a verified merchant. null when the destination is not a merchant. |
API reference
| Endpoint | Credential | Purpose |
|---|---|---|
| POST /v1/verdict | sk_ | Score a session. Creates a decision. |
| POST /v1/probe | sk_ or cs_ | Next probe question for an INTERVENE decision. |
| POST /v1/pattern | sk_ or cs_ | The pattern screen, after four answers. |
| POST /v1/outcome | sk_ 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/labels | sk_ | Report confirmed fraud or legitimate outcomes. |
| GET /v1/readout | sk_ | Tier distribution and accuracy against your labels. |
| GET /v1/decisions | sk_ | List decision records, newest first. |
| GET · PATCH /v1/settings | sk_ | Threshold profile and retention window. |
| GET /v1 | none | Health check. |
Every authenticated response carries a Straja-Request-Id header. Quote it when reporting a problem.
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.
{
"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
}
}{
"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.
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.
{
"decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
"transcript": [
{
"question": "How did you first hear about this opportunity?",
"answer": "Someone from the exchange called me."
}
]
}{
"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
}Send exactly four turns. Idempotent: repeating the call returns the screen already generated for this decision.
{
"decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
"transcript": [ /* all four turns */ ]
}{
"object": "pattern",
"decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
"headline": "…",
"observations": [
{ "signal": "…", "user_quote": "…" }
],
"the_pattern": "…",
"one_thing_to_check": "…",
"action": { "delay_hours": 24, "cancellable": true }
}outcome is one of cancelled, proceeded_after_delay, no_response. May be sent again to correct it; the latest value is kept.
{
"decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
"outcome": "cancelled"
}{
"object": "outcome",
"decision_id": "dec_9b1f0c7e2a4d5b6c8e0f1a2b",
"outcome": "cancelled",
"outcome_reported_at": "2026-09-29T10:44:03.511Z"
}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.
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.
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
{
"error": {
"type": "invalid_request_error",
"message": "signals.tab_switches: Invalid input: expected number, received undefined",
"param": "signals.tab_switches",
"request_id": "req_3f9a0c1d2b4e6f70"
}
}| Status | type | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_request_error | Body failed validation; param names the field. | No — fix the request |
| 401 | authentication_error | Missing, invalid, revoked or expired credential. | No |
| 403 | permission_error | Credential not allowed on this endpoint or decision. | No |
| 404 | not_found_error | No such decision for this key. | No |
| 409 | conflict_error | Probe on a non-INTERVENE decision, or after the pattern screen. | No |
| 413 | invalid_request_error | Body over 64 KB. | No |
| 429 | rate_limit_error | Per-key limit exceeded. | Yes, after Retry-After |
| 502 | model_unavailable_error | The model declined to complete the request. | Once |
| 503 | model_unavailable_error | Model overloaded after automatic failover. | Yes, after Retry-After |
| 500 | api_error | Error 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
Every live verdict is a model call. Measured over 525 recorded verdicts:
Shadow readout
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.
{
"labels": [
{ "reference": "order_8f2c41", "fraud": true },
{ "reference": "order_91aa07", "fraud": false },
{ "decision_id": "dec_9b1f…", "fraud": false }
]
}{
"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.
{
"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
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
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.
| Field | Type | Content |
|---|---|---|
| decision_id, created_at | string | Identifier and timestamp. |
| livemode, shadow | boolean | Live or test key; whether the tier was acted on. |
| reference | string | null | Your own identifier. |
| tier, threshold_profile | string | The final tier and the profile applied to it. |
| signals | object | The exact vector the decision was made from. |
| key_signals, reasoning | string[], string | What drove the tier, in plain language. |
| model, prompt_fingerprint | string | The model that answered and the version of the instructions it was given. |
| latency_ms | number | Time to verdict. |
| probe_transcript | array | Questions asked and answers given. |
| pattern | object | null | The pattern screen exactly as shown. |
| user_outcome, outcome_reported_at | string | null | What the user did, and when it was reported. |
Configurability
A deterministic step applied after the model, set per partner with PATCH /v1/settings.
| Profile | Effect |
|---|---|
| balanced | The model's own tier, unchanged. The default, and the profile the stability suite runs against. |
| conservative | A first purchase of €1,000 or more is at least SOFT; SOFT becomes INTERVENE for a fresh_eoa destination or any declined attempt. |
| permissive | SOFT under €500 with no declined attempts becomes PASS. INTERVENE is never lowered. |
Data handling
reference should be an opaque id, not personal data.reference or key.DELETE /v1/decisions/{id}, or automatically once older than the retention_days you set.Model
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.
Limitations
copy, but probe questions and the pattern screen are generated in English.Getting started
Integration runs in shadow mode before any user sees a Straja screen, so verdict accuracy is measured against your own outcomes first.
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.
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.
A test key. Build the server call and the screens against test mode — free, with forced tiers.
A live key on one payment corridor, sending "shadow": true and not acting on the tier. Typically four weeks.
Readout: report confirmed outcomes with POST /v1/labels and read recall and false-positive rates from GET /v1/readout?shadow=true.
Go live on that corridor: act on the tier and present the screens.