Stop card testing before it reaches your gateway.
Cardvera scores every checkout at the edge — browser, transport, velocity, and merchant-attested context — and hands your server a signed allow or block before you authorize the card. This reference is everything you need to integrate it.
How it works
A Cardvera integration is one embedded SDK tag and two server-side calls. Everything is authenticated with a JWT you sign using your Cardvera secret (HS256) — one mechanism, any JWT library. Your secret never leaves your backend, and Cardvera never sees a card number.
- Mint a signed SDK token. On each checkout render, your server signs a short-lived JWT whose claims describe the attempt — order id, flow, whether the shopper is signed in, amount. No API call; signing is local.
- Embed the SDK. The browser loads
<script src="…/v1/cv.js?t=<jwt>">. The SDK collects behavioural and device telemetry and solves a lightweight proof of work in the background. - Assess at submit. Your page calls
await Cardvera.assess(), which returns a verdict JWT — actionalloworblock. Step-ups are resolved inside the SDK; you only ever see the two terminal actions. - Verify, then charge. Your server verifies the verdict JWT with your key (pinned to HS256) and checks the order id claim against your record. Only a valid
allowauthorizes the card. - Report the outcome. After your PSP responds — and again if a chargeback lands later — your server
POSTs the disposition to/api/outcomewith a bearer JWT. This trains the block that stops the next attempt.
allow is only trustworthy once your server verifies the JWT signature with your key. Skipping step 4 defeats the product.<!-- 1. Your server signed this JWT and put it on the URL -->
<script src="https://cardvera.dev/v1/cv.js?t=eyJhbGciOiJIUzI1NiJ9…"></script>
<script>
// 2. At submit, ask Cardvera for a verdict.
form.addEventListener('submit', async (e) => {
e.preventDefault();
const v = await Cardvera.assess();
// v = { action: 'allow' | 'block', token: '<verdict JWT>' }
// 3. Hand the verdict JWT to YOUR server with the rest of
// checkout. Your server verifies it before charging.
hidden('cvToken', v.token);
form.submit();
});
</script>Authentication
Everything is a JWT signed with your Cardvera secret using HS256, so you can use any JWT library in your language. Signing is local — there's no API call to obtain a token.
Your keys
You get two keys, a primary and a secondary, so you can rotate without downtime. Both are accepted for inbound validation; you sign with the primary. Keys carry a readable prefix for breach triage:
| Prefix | Slot |
|---|---|
cv_sk_p_… | Primary |
cv_sk_s_… | Secondary |
cv_sk_p_ prefix — as the HS256 secret. (Rotation: stage the new key in the secondary slot, cut your signer over, then ask us to promote it.)The three tokens
Three JWTs appear across the API. All are HS256, signed with your key, short-lived via iat/exp.
| Token | Who signs | Key claims |
|---|---|---|
| Session (SDK URL) | You | iss=aid, sid, txn, flow, ident, sub?, amt?, cur? |
| Verdict | Cardvera | iss=cardvera, action, sid, txn |
| Outcome (bearer) | You | iss=aid, jti (fresh nonce) |
algorithms: ["HS256"] (or your library's equivalent). A verifier that accepts any algorithm can be tricked by an alg: none or algorithm-confusion token. Every library supports pinning in one line — this is the one JWT rule you must not skip.# A JWT is base64url(header).base64url(payload).base64url(HMAC).
# Any JWT tool works (jwt.io, jwt-cli); with openssl by hand:
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
KEY="cv_sk_p_test_0000111122223333"
H=$(printf '{"alg":"HS256","typ":"JWT"}' | b64)
P=$(printf '{"iss":"acct_7f2a","sid":"9c1d…","txn":"ORD-1","flow":"full_checkout","ident":"guest","iat":%s,"exp":%s}' \
"$(date +%s)" "$(( $(date +%s) + 1800 ))" | b64)
S=$(printf '%s.%s' "$H" "$P" | openssl dgst -sha256 -hmac "$KEY" -binary | b64)
echo "$H.$P.$S"import jwt from "jsonwebtoken"; // npm i jsonwebtoken
const KEY = process.env.CARDVERA_KEY; // cv_sk_p_… (whole string)
const token = jwt.sign(
{ iss: "acct_7f2a", sid, txn, flow: "full_checkout", ident: "guest" },
KEY,
{ algorithm: "HS256", expiresIn: "30m" }
);import jwt, time # pip install pyjwt
KEY = os.environ["CARDVERA_KEY"] # cv_sk_p_… (whole string)
token = jwt.encode(
{"iss": "acct_7f2a", "sid": sid, "txn": txn,
"flow": "full_checkout", "ident": "guest",
"iat": int(time.time()), "exp": int(time.time()) + 1800},
KEY, algorithm="HS256")<?php
use Firebase\JWT\JWT; // composer require firebase/php-jwt
$key = getenv('CARDVERA_KEY'); // cv_sk_p_… (whole string)
$now = time();
$token = JWT::encode([
'iss' => 'acct_7f2a', 'sid' => $sid, 'txn' => $txn,
'flow' => 'full_checkout', 'ident' => 'guest',
'iat' => $now, 'exp' => $now + 1800,
], $key, 'HS256');import "github.com/golang-jwt/jwt/v5" // go get github.com/golang-jwt/jwt/v5
key := []byte(os.Getenv("CARDVERA_KEY")) // cv_sk_p_… (whole string)
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "acct_7f2a", "sid": sid, "txn": txn,
"flow": "full_checkout", "ident": "guest",
"iat": time.Now().Unix(), "exp": time.Now().Add(30 * time.Minute).Unix(),
})
token, _ := tok.SignedString(key)using JWT.Builder; // dotnet add package JWT
using JWT.Algorithms;
var key = Environment.GetEnvironmentVariable("CARDVERA_KEY"); // whole string
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var token = JwtBuilder.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.WithSecret(key)
.AddClaim("iss", "acct_7f2a").AddClaim("sid", sid).AddClaim("txn", txn)
.AddClaim("flow", "full_checkout").AddClaim("ident", "guest")
.AddClaim("iat", now).AddClaim("exp", now + 1800)
.Encode();Context claims
The business context of a payment travels as claims in the session JWT — your backend's signed statement of which transaction, which flow, and whether the shopper is signed in. Because they're inside the signed token, the browser can read them but can never change them. Cardvera stores the verified claims at SDK fetch and reads only that copy for every decision.
The session token is required. A fetch without a valid one is rejected — there is no context-less fallback.
Claims
| Claim | Req | Value |
|---|---|---|
iss | yes | Your account id (acct_…). |
sid | yes | Fresh session nonce per checkout — the session id. 32 hex chars recommended. |
txn | yes | Your transaction / order id (≤200 chars). Echoed in the verdict for binding. |
flow | yes | full_checkout · express_checkout · minimal_payment · donation |
ident | yes | guest · authenticated_new · authenticated_established |
sub | no | Merchant-scoped pseudonymous subject id (≤128 chars). Omit for guests. See below. |
amt | no | Integer in the currency's smallest unit — e.g. 31104 = $311.04 (USD/EUR, 2 digits), 1000 = ¥1000 (JPY, 0 digits), 5000 = 5.000 KWD (3 digits). Never a decimal. Requires cur. |
cur | with amt | ISO-4217, upper-case, e.g. USD. |
iat, exp | yes | Issued-at and expiry (Unix seconds). Keep the window tight — e.g. 30 min. |
Your JWT library encodes and signs these; you never touch base64url or the signature yourself. Unknown flow/ident, a missing required claim, or an expired token are rejected at SDK fetch — an unknown flow never falls through to the most permissive policy.
Deriving sub
For a signed-in customer, derive the subject from your immutable internal customer id with a dedicated key — not your Cardvera signing key: sub = HMAC(subjectKey, "subject:v1:" + internalCustomerId). It is stable for that customer at your store, different at every other merchant, and not reversible by Cardvera. Never send a raw id or an unsalted hash of an email.
What is never a claim: card number, CVV, cardholder name, raw email or phone, addresses, raw customer ids.
Choosing a flow
Send the profile that matches the page the shopper is on. The single most common mistake is defaulting everything to full_checkout — a bare donation form scored as if it were a full checkout is the classic false positive.
| flow | Send it when |
|---|---|
full_checkout | A standard multi-field cart — name, address, card. Meaningful dwell and interaction. The default for normal e-commerce. |
express_checkout | A shortened, returning-customer path — wallet, saved card, one-tap (Apple Pay, Shop Pay). Less on-page interaction is expected and normal. |
minimal_payment | A small form with little behavioural surface — a "pay this invoice" link, an amount and a card field, not much else. |
donation | Often guest-accessible, a fixed or chosen amount, low navigation depth. Land, pick an amount, give. |
What it does in the system.
- Sets the behavioural baseline. A
minimal_paymentordonationpage legitimately produces little mouse/keyboard evidence; declaring the flow stops that thinness from counting against a real shopper the way it would on a full checkout. - Isolates detection cohorts. Decline-rate and volume anomalies are measured per flow, so a card-testing spike on your donation page is caught on its own baseline instead of being diluted by — or falsely implicating — checkout traffic.
- Is validated fail-closed. An unrecognised flow is rejected at SDK fetch; it never silently falls back to the most permissive profile.
Choosing an ident
State what is true about the shopper's session with you. ident selects which population the session is judged against — it is not a trust dial.
| ident | Send it when |
|---|---|
guest | No account, or not signed in for this purchase. There is no sub. The verdict leans on browser, transport, velocity and behaviour. |
authenticated_new | Signed in, but without earned history — a fresh account, or one that doesn't yet meet your "established" bar. Send a sub. |
authenticated_established | A signed-in account that meets criteria you define — e.g. N prior settled orders, or an account age threshold. Send a sub. |
What it does in the system.
- Selects the population & isolates cohorts. Guest sessions are judged and aggregated separately from authenticated ones, so guest card-testing never pollutes — or borrows credibility from — your signed-in customers.
- Gates account-level protection. The
sub-keyed defense that survives IP and device rotation only applies toauthenticated_*sessions. A guest has no account to key on. - Is a label, not a bypass.
authenticated_establisheddoes not, by itself, relax any control — it never overrides a block, and it earns no automatic pass today. Send it because it's accurate, not to get looser treatment.
{
"iss": "acct_7f2a",
"sid": "9c1d4e77b0a3f5628d10c4ba9e73f001",
"txn": "ORD-20260921-abc123",
"flow": "full_checkout",
"ident": "authenticated_new",
"sub": "3f1c9a0b7d2e4f6a…",
"amt": 31104,
"cur": "USD",
"iat": 1787000000,
"exp": 1787001800
}
// HS256, signed with your key -> the "t" URL parameterQuickstart
A complete server-side integration is three functions: mint a session token, verify the verdict, and report the outcome. All three are one JWT-library call plus a little glue. The rail has a self-contained module in your language — set your aid and key, wire the three call sites.
1 · Render the checkout
Call mintSdkUrl(orderId, …), drop the returned <script> into the page, and record the sid→orderId mapping server-side. You verify the verdict against your stored order id, never the browser's copy.
2 · Gate the charge
The browser posts back the verdict JWT from Cardvera.assess(). Call verifyVerdict(token, expectedOrderId) — it verifies the signature pinned to HS256, checks expiry, and confirms the txn claim matches your record. Authorize only when it returns an allow.
3 · Close the loop
After the PSP responds, call reportOutcome(sid, "auth", outcome, …) — it signs a bearer JWT and POSTs the disposition. Report chargebacks and refunds the same way. This feed drives the blocks that stop repeat and follow-on attempts.
acct_…), your primary key (cv_sk_p_…, used whole), and — if you send sub — a separate subject key you generate. Keep keys in your secret store, never in client code.# A JWT is just base64url(header).base64url(payload).HMAC — buildable
# with openssl, but any JWT tool (jwt-cli, jwt.io) is easier. Helpers:
KEY="cv_sk_p_test_0000111122223333"; AID="acct_7f2a"; HOST="https://cardvera.dev"
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
jwt() { # $1 = payload JSON
local h p s; h=$(printf '{"alg":"HS256","typ":"JWT"}' | b64); p=$(printf '%s' "$1" | b64)
s=$(printf '%s.%s' "$h" "$p" | openssl dgst -sha256 -hmac "$KEY" -binary | b64)
printf '%s.%s.%s' "$h" "$p" "$s"; }
# 1 · MINT — session token -> SDK URL. Persist SID -> TXN yourself.
SID=$(openssl rand -hex 16); TXN="ORD-20260921-abc123"; NOW=$(date +%s)
T=$(jwt "{\"iss\":\"$AID\",\"sid\":\"$SID\",\"txn\":\"$TXN\",\"flow\":\"full_checkout\",\"ident\":\"guest\",\"amt\":31104,\"cur\":\"USD\",\"iat\":$NOW,\"exp\":$((NOW+1800))}")
echo "$HOST/v1/cv.js?t=$T"
# 2 · VERIFY the verdict JWT — pin HS256, then check claims yourself.
# Easiest with a real JWT lib; conceptually:
# verify signature with KEY (alg HS256 only)
# exp not passed; claims.txn == your stored order id; claims.action == "allow"
# 3 · REPORT — bearer JWT + data-only body.
OT=$(jwt "{\"iss\":\"$AID\",\"jti\":\"$(openssl rand -hex 16)\",\"iat\":$(date +%s),\"exp\":$(( $(date +%s)+300 ))}")
curl -sS -X POST "$HOST/api/outcome" -H "Authorization: Bearer $OT" \
-H 'Content-Type: application/json' -d @- <<JSON
{ "sessionId":"$SID","outcomeType":"auth","outcome":"declined_fraud",
"occurredAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","outcomeId":"psp_abc" }
JSONimport jwt from "jsonwebtoken"; // npm i jsonwebtoken
import crypto from "node:crypto";
const AID = "acct_7f2a";
const KEY = process.env.CARDVERA_KEY; // cv_sk_p_… (whole string)
const HOST = "https://cardvera.dev";
// 1 · MINT — returns { url, sid }. Persist sid -> txn yourself.
export function mintSdkUrl({ txn, flow = "full_checkout", ident = "guest", sub, amt, cur }) {
const sid = crypto.randomBytes(16).toString("hex");
const claims = { iss: AID, sid, txn, flow, ident };
if (sub) claims.sub = sub;
if (amt != null) { claims.amt = amt; claims.cur = cur || "USD"; }
const t = jwt.sign(claims, KEY, { algorithm: "HS256", expiresIn: "30m" });
return { url: `${HOST}/v1/cv.js?t=${t}`, sid };
}
// 2 · VERIFY — expectedTxn is the order id YOU stored for this sid.
export function verifyVerdict(token, expectedTxn) {
try {
const c = jwt.verify(token, KEY, { algorithms: ["HS256"] }); // pin HS256
return c.action === "allow" && c.txn === expectedTxn ? c : null;
} catch { return null; } // bad sig, expired, wrong alg
}
// 3 · REPORT — bearer JWT + data-only body.
export async function reportOutcome(sid, outcomeType, outcome, outcomeId) {
const bearer = jwt.sign({ iss: AID, jti: crypto.randomBytes(16).toString("hex") },
KEY, { algorithm: "HS256", expiresIn: "5m" });
await fetch(`${HOST}/api/outcome`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
body: JSON.stringify({ sessionId: sid, outcomeType, outcome,
occurredAt: new Date().toISOString(), outcomeId }),
});
}import jwt, os, secrets, time, urllib.request, json # pip install pyjwt
AID = "acct_7f2a"
KEY = os.environ["CARDVERA_KEY"] # cv_sk_p_… (whole string)
HOST = "https://cardvera.dev"
# 1 · MINT — returns (url, sid). Persist sid -> txn yourself.
def mint_sdk_url(txn, flow="full_checkout", ident="guest", sub=None, amt=None, cur="USD"):
sid = secrets.token_hex(16); now = int(time.time())
claims = {"iss": AID, "sid": sid, "txn": txn, "flow": flow, "ident": ident,
"iat": now, "exp": now + 1800}
if sub: claims["sub"] = sub
if amt is not None: claims["amt"] = amt; claims["cur"] = cur
t = jwt.encode(claims, KEY, algorithm="HS256")
return f"{HOST}/v1/cv.js?t={t}", sid
# 2 · VERIFY — expected_txn is the order id YOU stored for this sid.
def verify_verdict(token, expected_txn):
try:
c = jwt.decode(token, KEY, algorithms=["HS256"]) # pin HS256
except jwt.InvalidTokenError:
return None # bad sig / expired / wrong alg
return c if c.get("action") == "allow" and c.get("txn") == expected_txn else None
# 3 · REPORT — bearer JWT + data-only body.
def report_outcome(sid, outcome_type, outcome, outcome_id=None):
now = int(time.time())
bearer = jwt.encode({"iss": AID, "jti": secrets.token_hex(16),
"iat": now, "exp": now + 300}, KEY, algorithm="HS256")
body = {"sessionId": sid, "outcomeType": outcome_type, "outcome": outcome,
"occurredAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "outcomeId": outcome_id}
req = urllib.request.Request(f"{HOST}/api/outcome", method="POST",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {bearer}"})
urllib.request.urlopen(req, timeout=3)<?php
use Firebase\JWT\JWT; // composer require firebase/php-jwt
use Firebase\JWT\Key;
const CV_AID = 'acct_7f2a';
const CV_HOST = 'https://cardvera.dev';
function cv_key(): string { return getenv('CARDVERA_KEY'); } // whole string
// 1 · MINT — returns ['url' => ..., 'sid' => ...]. Persist sid -> txn.
function cv_mint(string $txn, string $flow = 'full_checkout', string $ident = 'guest',
?string $sub = null, ?int $amt = null, string $cur = 'USD'): array {
$sid = bin2hex(random_bytes(16)); $now = time();
$claims = ['iss' => CV_AID, 'sid' => $sid, 'txn' => $txn, 'flow' => $flow,
'ident' => $ident, 'iat' => $now, 'exp' => $now + 1800];
if ($sub !== null) $claims['sub'] = $sub;
if ($amt !== null) { $claims['amt'] = $amt; $claims['cur'] = $cur; }
$t = JWT::encode($claims, cv_key(), 'HS256');
return ['url' => CV_HOST . "/v1/cv.js?t=$t", 'sid' => $sid];
}
// 2 · VERIFY — $expectedTxn is the order id YOU stored for this sid.
function cv_verify(string $token, string $expectedTxn): ?object {
try { $c = JWT::decode($token, new Key(cv_key(), 'HS256')); } // pin HS256
catch (\Throwable $e) { return null; } // bad sig / expired
return ($c->action === 'allow' && $c->txn === $expectedTxn) ? $c : null;
}
// 3 · REPORT — bearer JWT + data-only body.
function cv_report(string $sid, string $type, string $outcome, ?string $outcomeId = null): void {
$now = time();
$bearer = JWT::encode(['iss' => CV_AID, 'jti' => bin2hex(random_bytes(16)),
'iat' => $now, 'exp' => $now + 300], cv_key(), 'HS256');
$body = ['sessionId' => $sid, 'outcomeType' => $type, 'outcome' => $outcome,
'occurredAt' => gmdate('Y-m-d\TH:i:s\Z'), 'outcomeId' => $outcomeId];
$c = curl_init(CV_HOST . '/api/outcome');
curl_setopt_array($c, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer $bearer"],
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3]);
curl_exec($c); curl_close($c);
}package cardvera
// go get github.com/golang-jwt/jwt/v5
import ( "bytes"; "crypto/rand"; "encoding/hex"; "encoding/json"; "fmt"
"net/http"; "os"; "time"; "github.com/golang-jwt/jwt/v5" )
const aid, host = "acct_7f2a", "https://cardvera.dev"
func key() []byte { return []byte(os.Getenv("CARDVERA_KEY")) } // whole string
func randHex() string { b := make([]byte, 16); rand.Read(b); return hex.EncodeToString(b) }
// 1 · MINT — returns url, sid. Persist sid -> txn yourself.
func MintSdkURL(txn, flow, ident string, amt int64, cur string) (string, string) {
sid := randHex()
claims := jwt.MapClaims{ "iss": aid, "sid": sid, "txn": txn, "flow": flow,
"ident": ident, "iat": time.Now().Unix(), "exp": time.Now().Add(30 * time.Minute).Unix() }
if amt > 0 { claims["amt"] = amt; claims["cur"] = cur }
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key())
return fmt.Sprintf("%s/v1/cv.js?t=%s", host, t), sid
}
// 2 · VERIFY — expectedTxn is the order id YOU stored for this sid.
func VerifyVerdict(token, expectedTxn string) (jwt.MapClaims, bool) {
t, err := jwt.Parse(token, func(*jwt.Token) (any, error) { return key(), nil },
jwt.WithValidMethods([]string{"HS256"})) // pin HS256
if err != nil || !t.Valid { return nil, false }
c := t.Claims.(jwt.MapClaims)
return c, c["action"] == "allow" && c["txn"] == expectedTxn
}
// 3 · REPORT — bearer JWT + data-only body.
func ReportOutcome(sid, outcomeType, outcome, outcomeID string) error {
bearer, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": aid, "jti": randHex(), "iat": time.Now().Unix(),
"exp": time.Now().Add(5 * time.Minute).Unix() }).SignedString(key())
body, _ := json.Marshal(map[string]any{ "sessionId": sid, "outcomeType": outcomeType,
"outcome": outcome, "occurredAt": time.Now().UTC().Format(time.RFC3339), "outcomeId": outcomeID })
req, _ := http.NewRequest("POST", host+"/api/outcome", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+bearer)
_, err := http.DefaultClient.Do(req)
return err
}// dotnet add package JWT
using JWT.Builder; using JWT.Algorithms; using System.Security.Cryptography;
public static class Cardvera {
const string Aid = "acct_7f2a";
const string Host = "https://cardvera.dev";
static string Key => Environment.GetEnvironmentVariable("CARDVERA_KEY")!; // whole string
static readonly HttpClient Http = new();
static long Now() => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
static string RandHex() => Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
// 1 · MINT — returns (url, sid). Persist sid -> txn yourself.
public static (string Url, string Sid) MintSdkUrl(string txn, string flow = "full_checkout",
string ident = "guest", string? sub = null, long? amt = null, string cur = "USD") {
var sid = RandHex();
var b = JwtBuilder.Create().WithAlgorithm(new HMACSHA256Algorithm()).WithSecret(Key)
.AddClaim("iss", Aid).AddClaim("sid", sid).AddClaim("txn", txn)
.AddClaim("flow", flow).AddClaim("ident", ident)
.AddClaim("iat", Now()).AddClaim("exp", Now() + 1800);
if (sub is not null) b.AddClaim("sub", sub);
if (amt is not null) { b.AddClaim("amt", amt); b.AddClaim("cur", cur); }
return ($"{Host}/v1/cv.js?t={b.Encode()}", sid);
}
// 2 · VERIFY — expectedTxn is the order id YOU stored for this sid.
public static IDictionary<string, object>? VerifyVerdict(string token, string expectedTxn) {
try {
var c = JwtBuilder.Create().WithAlgorithm(new HMACSHA256Algorithm()) // pin HS256
.WithSecret(Key).MustVerifySignature()
.Decode<IDictionary<string, object>>(token);
return (string)c["action"] == "allow" && (string)c["txn"] == expectedTxn ? c : null;
} catch { return null; }
}
// 3 · REPORT — bearer JWT + data-only body.
public static async Task ReportOutcome(string sid, string type, string outcome, string? outcomeId = null) {
var bearer = JwtBuilder.Create().WithAlgorithm(new HMACSHA256Algorithm()).WithSecret(Key)
.AddClaim("iss", Aid).AddClaim("jti", RandHex())
.AddClaim("iat", Now()).AddClaim("exp", Now() + 300).Encode();
var body = System.Text.Json.JsonSerializer.Serialize(new { sessionId = sid,
outcomeType = type, outcome, occurredAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), outcomeId });
var req = new HttpRequestMessage(HttpMethod.Post, $"{Host}/api/outcome") {
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
req.Headers.Add("Authorization", $"Bearer {bearer}");
await Http.SendAsync(req);
}
}Tokens & proof of work
Three JWTs, all HS256, all signed with your key used whole (prefix included). Keep lifetimes short via iat/exp. The claim sets are on the right.
The rules that matter
- Pin HS256 on every verify. Pass
algorithms: ["HS256"]. Never let a verifier acceptalg: noneor an algorithm you didn't choose. - The key is the whole issued string, prefix included — use it verbatim as the HS256 secret.
- Sign with primary. Cardvera validates against your primary or secondary, so rotation is: stage new key in secondary, cut over, promote.
- Short windows. A session token ~30 min (it matches the session TTL); verdict and outcome tokens ~5 min. Give your verifier a little clock leeway.
expis required and capped. Cardvera rejects a token you sign with noexp, or whoseexpis more than 30 minutes away. It allows 60 seconds of clock skew and honoursnbfif you set it.- Claims are readable (signed, not encrypted) — never put a secret in one.
Proof of work
The SDK solves a small hashcash-style challenge in a background worker and attaches the proof to every assessment. You never touch it. A borderline session may get a harder step-up challenge; the SDK solves and re-submits it transparently, so your server still only ever sees a terminal allow or block.
// 1 · Session (you sign; on the SDK URL as ?t=)
{ "iss":"acct_7f2a", "sid":"9c1d…", "txn":"ORD-1",
"flow":"full_checkout", "ident":"guest",
"sub":"…", "amt":31104, "cur":"USD", "iat":…, "exp":… }
// 2 · Verdict (Cardvera signs with YOUR key; you verify)
{ "iss":"cardvera", "action":"allow", "sid":"9c1d…",
"txn":"ORD-1", "iat":…, "exp":… }
// 3 · Outcome bearer (you sign; Authorization: Bearer)
{ "iss":"acct_7f2a", "jti":"<fresh nonce>", "iat":…, "exp":… }Mint the SDK URL
Done entirely on your server — no Cardvera call. Generate a fresh sid, sign the session JWT (see Quickstart), and put it on the URL as t. Record the sid→txn mapping; you need it to verify the verdict.
| Param | Value |
|---|---|
t | The session JWT (HS256, your key). Carries every context claim; nothing else goes on the URL. |
sid across page loads — the second render collides with the first session's record. One checkout, one nonce, one token.GET https://cardvera.dev/v1/cv.js?t=eyJhbGciOiJIUzI1NiJ9.eyJpc3Mi…
200 application/javascript -> serves the SDK
400 { "error": "missing_token" } -> no t param
400 { "error": "invalid_claims" } -> unknown flow/ident, missing txn
403 { "error": "invalid_token" } -> bad signature, wrong alg, or expiredThe SDK & assess()
GET /v1/cv.js?t=<jwt> returns the Cardvera SDK as JavaScript. Embed the minted URL with a normal <script> tag. The SDK starts collecting telemetry and solving the base proof of work on parse; it is served no-store, so don't cache or bundle it.
Cardvera.assess()
Call it when the shopper submits — it resolves to the verdict JWT. Any step-up is solved and re-submitted inside the SDK before the promise resolves, so your page never handles it.
| Returns | |
|---|---|
action | "allow" or "block" — for your client-side branching only. |
token | The verdict JWT. Send this to your server; it's the value your server verifies. |
action as an untrusted hint until your server has verified token. An attacker can patch assess() to return allow; only the signed JWT, checked with your key, is real.<!-- URL minted by your server (see Quickstart) -->
<script src="https://cardvera.dev/v1/cv.js?t=eyJhbGciOiJIUzI1NiJ9…"></script>
<script>
const form = document.getElementById('checkout');
form.addEventListener('submit', async (e) => {
e.preventDefault();
let token = '';
try {
const v = await Cardvera.assess(); // { action, token }
token = v.token;
} catch (err) {
// network/SDK failure -> empty token -> server fails closed
}
setHidden('cvToken', token);
form.submit(); // POST to your server; verify there
});
</script>Verify the verdict
Your page posts the verdict JWT to your server with the checkout submission. Verify it before you authorize the card. It's a local jwt.verify — no Cardvera call. The Quickstart module's verifyVerdict does all of this.
What to check, in order
- A token is present. Empty → decline.
- Signature verifies with your key, pinned to HS256. This alone defeats a forged or patched verdict.
- Not expired — the library checks
exp; allow small clock leeway. - You have a recorded
txnfor thissid, and the token'stxnclaim equals it. No record → decline. action == "allow"before charging.
txn you hold — never one the browser sends — stops a valid allow from a cheap order being replayed onto an expensive one. A verdict minted for order A only passes when you check it against order A's id.{
"iss": "cardvera",
"action": "allow", // or "block"
"sid": "9c1d4e77b0a3f5628d10c4ba9e73f001",
"txn": "ORD-20260921-abc123",
"iat": 1787000019,
"exp": 1787000319,
"agt": { // only when a verified AI agent drove the session
"op": "openai", "tier": "MajorOperator",
"tag": "web-bot-auth", "mode": "enforce"
}
}
// verify(token, key, { algorithms: ["HS256"] })
// then: claims.action == "allow" AND claims.txn == your stored order idReport outcomes
POST /api/outcome — tell Cardvera what your PSP decided. This is the feedback loop: confirmed declines and chargebacks train the blocks that stop the next attempt. Call it server-side after each PSP response, and again when a chargeback or refund lands later. Fire-and-forget — don't block checkout on it.
Authenticated with a bearer JWT (Authorization: Bearer <jwt>), claims { iss, jti, iat, exp } signed HS256 with your key. The jti is a fresh nonce per call — one session reports many outcomes over its life, each with its own bearer. The body is pure data; you never resend context (Cardvera joins by sessionId).
Body
| Field | Req | |
|---|---|---|
sessionId | yes | The sid from the session token. |
outcomeType | yes | auth · chargeback · refund |
outcome | yes | Closed vocabulary — see below. |
occurredAt | yes | ISO-8601, when the PSP event happened. |
outcomeId | no | Idempotency key (e.g. PSP txn id). Dedupes on (sessionId, outcomeType, outcomeId). Without it, Cardvera dedupes on (sessionId, outcomeType, outcome, occurredAt). |
pspName | no | Audit only, e.g. stripe. |
pspRawCode | no | Audit only: the PSP's own decline code, e.g. card_declined. |
Outcome vocabulary
| outcomeType | outcome values |
|---|---|
auth | approved, declined_fraud, declined_insufficient_funds, declined_do_not_honor, declined_card_invalid, declined_3ds_failed, declined_other, declined_technical |
chargeback | chargeback_fraud, chargeback_other |
refund | refund_customer, refund_merchant |
declined_technical (weight zero). Mapping outages to declined_other poisons your own signal and can friction real shoppers during an incident.jti is regenerated on each edit — a fresh nonce per report.202 Accepted — recorded, move on
(empty body)
400 invalid_outcome
{ "error": "invalid_outcome" }
// also: missing_session_id, invalid_outcome_type, invalid_occurred_at401 invalid_token
{ "error": "invalid_token" } // bad/expired bearer, or wrong alg
{ "error": "missing_token" } // no Authorization: Bearer header503 storage_unavailable
{ "error": "storage_unavailable" } // safe to retrycurl -X POST https://cardvera.dev/api/outcome \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3Mi…" \
-H 'Content-Type: application/json' \
-d '{
"sessionId": "9c1d4e77b0a3f5628d10c4ba9e73f001",
"outcomeType":"auth",
"outcome": "declined_fraud",
"occurredAt": "2026-09-21T14:32:00Z",
"outcomeId": "stripe_pi_3OABCxyz",
"pspName": "stripe"
}'
202 Accepted -> recorded, move on
400 { "error": "invalid_outcome" } -> or missing_session_id, invalid_outcome_type, invalid_occurred_at
401 { "error": "invalid_token" } -> bad/expired bearer, wrong alg
503 { "error": "storage_unavailable" } -> safe to retryVerified AI agents
AI agents that shop on a customer's behalf, like ChatGPT Agent or agents enrolled in Visa's Trusted Agent Protocol, drive a real browser through your iframe checkout. Many sign every request with Web Bot Auth (RFC 9421 HTTP message signatures). That includes the requests the browser makes to Cardvera. You don't need to integrate anything. Cardvera verifies the signature on the SDK fetch and on assess().
What a verified agent gets
An agent isn't a human, so Cardvera stops judging it on the "is this a human browser?" layer (mouse, typing and automation markers). That's the only change. Proof of work, blocklists, velocity, decline history and card-testing detection still apply, and an agent can still be stepped up or blocked.
- Only allowlisted operators count. Cardvera trusts a signature only from a key directory on its reviewed list (payment networks and major AI operators). A valid signature from any other operator gets no special treatment.
- Forged, replayed or expired signatures are always blocked. No real client produces one.
- Rollout is per merchant. Accounts start in
shadow: Cardvera verifies agents and records what it would have decided, but judges them like any other client. Ask us to switch your account toenforce.
The agt claim
When a verified agent drove the session, the verdict carries agt with op (operator), tier, tag and mode. It's signed with the rest of the verdict, so it's as trustworthy as action. You can ignore it, log it, or apply your own policy, for example requiring a signed-in account for agent orders. Its absence means no verified agent.
GET /v1/cv.js?t=eyJ... HTTP/2
Host: edge.cardvera.dev
Signature-Agent: "https://chatgpt.com"
Signature-Input: sig1=("@authority" "@method" "@path");
created=1787000000;expires=1787000060;
keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";
tag="web-bot-auth"
Signature: sig1=:jdq0SqOwHdyHr9+r5jw3iYZH6aNGKijYp/EstF4RQTQ=:
// Cardvera: fetch the operator's key directory (cached),
// rebuild the signature base, verify, reject replays.Errors
Token failures are deliberately terse — they don't say whether the signature, algorithm, or expiry was the problem, so they can't be used as an oracle. Validation failures return 400 with a specific error you can act on.
Status codes
| Code | Meaning |
|---|---|
| 200 | Allow / step-up (SDK & assess). |
| 202 | Outcome accepted. |
| 400 | Validation error — see error. |
| 401 / 403 | Invalid token, or a block verdict. |
| 503 | Storage unavailable — safe to retry. |
| 500 | internal_error — safe to retry. |
Common error values
| error | Fix |
|---|---|
missing_token | SDK URL has no t param (400), or no bearer on /api/outcome (401). |
invalid_token | Signature failed, wrong algorithm, or expired. Check you signed with the whole key and set alg HS256. |
invalid_claims | Unknown flow/ident, or a missing required claim (e.g. txn). |
missing_session_id | /api/outcome body has no sessionId. |
invalid_outcome_type | outcomeType is not auth, chargeback or refund. |
invalid_outcome | outcome is not in the vocabulary for its outcomeType. |
invalid_occurred_at | occurredAt is missing or not an ISO-8601 timestamp. |
agent_auth_invalid | The request carried a Web Bot Auth signature that failed verification (forged, replayed or expired). Not something your integration causes; the client is blocked. |
{ "error": "invalid_outcome" }
// Token failures carry no detail by design:
// 401 { "error": "invalid_token" } (POST /api/outcome)
// 403 { "error": "invalid_token" } (GET /v1/cv.js)Production checklist
- Secrets server-side only. Your
cv_sk_p_key and subject key live in your secret store, never in client code or the repo. - Use a JWT library and sign HS256. Don't hand-roll the encoding.
- Pin HS256 on every verify —
algorithms: ["HS256"]. Never acceptalg: noneor an unpinned algorithm. - Use the key whole — the full
cv_sk_p_…string is the HS256 secret. - Verify every verdict. No path authorizes a card on the client
actionwithout verifying the JWT and matching your storedtxn. - Fail closed. If
assess()throws, the token is empty, or verification fails, decline — don't fall through to a charge. - Fresh
sidper checkout render, mapped to your order id server-side. Keep token lifetimes short. - Clock on NTP. Expired-token errors on live traffic are almost always server clock drift.
- Wire
/api/outcome. Report every auth result, and chargebacks/refunds when they land. Map faults todeclined_technical, notdeclined_other. - Send
subfor signed-in customers, derived with a dedicated key — it enables account-level protection that survives IP and device rotation. - Rotate with the secondary slot — stage the new key in
cv_sk_s_, cut your signer over, then promote.
aid and a primary/secondary key pair to drop into the Quickstart module.