cardvera docs
Base URL https://cardvera.dev
Cardvera API v1

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.

Base URL https://cardvera.dev Auth JWT · HS256 No cardholder data One SDK tag + two server calls
Overview

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.

  1. 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.
  2. 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.
  3. Assess at submit. Your page calls await Cardvera.assess(), which returns a verdict JWT — action allow or block. Step-ups are resolved inside the SDK; you only ever see the two terminal actions.
  4. 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 allow authorizes the card.
  5. Report the outcome. After your PSP responds — and again if a chargeback lands later — your server POSTs the disposition to /api/outcome with a bearer JWT. This trains the block that stops the next attempt.
The security boundary is your serverThe browser is hostile. An attacker can patch the SDK, rewrite the network response, or replay an old verdict. None of that matters: an allow is only trustworthy once your server verifies the JWT signature with your key. Skipping step 4 defeats the product.
checkout.html — the whole client side
<!-- 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

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:

PrefixSlot
cv_sk_p_…Primary
cv_sk_s_…Secondary
Use the whole key as the secretPass the key string exactly as issued — including the 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.

TokenWho signsKey claims
Session (SDK URL)Youiss=aid, sid, txn, flow, ident, sub?, amt?, cur?
VerdictCardveraiss=cardvera, action, sid, txn
Outcome (bearer)Youiss=aid, jti (fresh nonce)
Always pin the algorithm to HS256When you verify the verdict, pass 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.
sign — a session token
# 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();
Core concept

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

ClaimReqValue
issyesYour account id (acct_…).
sidyesFresh session nonce per checkout — the session id. 32 hex chars recommended.
txnyesYour transaction / order id (≤200 chars). Echoed in the verdict for binding.
flowyesfull_checkout · express_checkout · minimal_payment · donation
identyesguest · authenticated_new · authenticated_established
subnoMerchant-scoped pseudonymous subject id (≤128 chars). Omit for guests. See below.
amtnoInteger 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.
curwith amtISO-4217, upper-case, e.g. USD.
iat, expyesIssued-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.

Claims are readable, not secretA JWT payload is signed, not encrypted — anyone with the URL can decode the claims. That's fine for what's here (a pseudonymous subject, a flow, an amount) and is the same as any signed token. Never put anything confidential in a claim.

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.

flowSend it when
full_checkoutA standard multi-field cart — name, address, card. Meaningful dwell and interaction. The default for normal e-commerce.
express_checkoutA shortened, returning-customer path — wallet, saved card, one-tap (Apple Pay, Shop Pay). Less on-page interaction is expected and normal.
minimal_paymentA small form with little behavioural surface — a "pay this invoice" link, an amount and a card field, not much else.
donationOften 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_payment or donation page 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.

identSend it when
guestNo account, or not signed in for this purchase. There is no sub. The verdict leans on browser, transport, velocity and behaviour.
authenticated_newSigned in, but without earned history — a fresh account, or one that doesn't yet meet your "established" bar. Send a sub.
authenticated_establishedA 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 to authenticated_* sessions. A guest has no account to key on.
  • Is a label, not a bypass. authenticated_established does 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.
session token — decoded claims
{
  "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 parameter
Quickstart

Quickstart

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.

ConfigThree values from onboarding: your account id (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.
cardvera — mint · verify · report
# 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" }
JSON
import 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);
    }
}
Core concept

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 accept alg: none or 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.
  • exp is required and capped. Cardvera rejects a token you sign with no exp, or whose exp is more than 30 minutes away. It allows 60 seconds of clock skew and honours nbf if 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.

claims — the three tokens
// 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":… }
Reference

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.

ParamValue
tThe session JWT (HS256, your key). Carries every context claim; nothing else goes on the URL.
Fresh sid per renderNever reuse a sid across page loads — the second render collides with the first session's record. One checkout, one nonce, one token.
Token buildersession JWT & SDK URL
Runs entirely in your browser. Signs a real HS256 JWT locally via WebCrypto — nothing is sent. Leave the test key, or paste your own; it never leaves this page.
outputHS256 JWT · header.payload.sig

            
GET /v1/cv.js — the resulting request
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 expired
Reference

The 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.
tokenThe verdict JWT. Send this to your server; it's the value your server verifies.
Never trust the client actionTreat 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.
client — embed & assess
<!-- 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>
Reference

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

  1. A token is present. Empty → decline.
  2. Signature verifies with your key, pinned to HS256. This alone defeats a forged or patched verdict.
  3. Not expired — the library checks exp; allow small clock leeway.
  4. You have a recorded txn for this sid, and the token's txn claim equals it. No record → decline.
  5. action == "allow" before charging.
Why txn is a claim you re-checkBinding the verdict to a 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.
verdict — decoded claims
{
  "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 id
Reference

Report 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

FieldReq
sessionIdyesThe sid from the session token.
outcomeTypeyesauth · chargeback · refund
outcomeyesClosed vocabulary — see below.
occurredAtyesISO-8601, when the PSP event happened.
outcomeIdnoIdempotency key (e.g. PSP txn id). Dedupes on (sessionId, outcomeType, outcomeId). Without it, Cardvera dedupes on (sessionId, outcomeType, outcome, occurredAt).
pspNamenoAudit only, e.g. stripe.
pspRawCodenoAudit only: the PSP's own decline code, e.g. card_declined.

Outcome vocabulary

outcomeTypeoutcome values
authapproved, declined_fraud, declined_insufficient_funds, declined_do_not_honor, declined_card_invalid, declined_3ds_failed, declined_other, declined_technical
chargebackchargeback_fraud, chargeback_other
refundrefund_customer, refund_merchant
Map gateway faults to declined_technicalA timeout or issuer-unavailable is operational, not fraud evidence — map it to declined_technical (weight zero). Mapping outages to declined_other poisons your own signal and can friction real shoppers during an incident.
Request builderbearer JWT + body
Runs entirely in your browser. The bearer JWT is signed locally with WebCrypto; nothing is sent. The jti is regenerated on each edit — a fresh nonce per report.
outputAuthorization: Bearer <jwt> + body

            
202 Accepted — recorded, move on
(empty body)
400 invalid_outcome
{ "error": "invalid_outcome" }
// also: missing_session_id, invalid_outcome_type, invalid_occurred_at
401 invalid_token
{ "error": "invalid_token" }   // bad/expired bearer, or wrong alg
{ "error": "missing_token" }   // no Authorization: Bearer header
503 storage_unavailable
{ "error": "storage_unavailable" }   // safe to retry
POST /api/outcome
curl -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 retry
Concepts

Verified 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 to enforce.

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.

agent request — what the agent's browser sends
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.
Reference

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

CodeMeaning
200Allow / step-up (SDK & assess).
202Outcome accepted.
400Validation error — see error.
401 / 403Invalid token, or a block verdict.
503Storage unavailable — safe to retry.
500internal_error — safe to retry.

Common error values

errorFix
missing_tokenSDK URL has no t param (400), or no bearer on /api/outcome (401).
invalid_tokenSignature failed, wrong algorithm, or expired. Check you signed with the whole key and set alg HS256.
invalid_claimsUnknown flow/ident, or a missing required claim (e.g. txn).
missing_session_id/api/outcome body has no sessionId.
invalid_outcome_typeoutcomeType is not auth, chargeback or refund.
invalid_outcomeoutcome is not in the vocabulary for its outcomeType.
invalid_occurred_atoccurredAt is missing or not an ISO-8601 timestamp.
agent_auth_invalidThe request carried a Web Bot Auth signature that failed verification (forged, replayed or expired). Not something your integration causes; the client is blocked.
400 — validation error shape
{ "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)
Go live

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 accept alg: none or 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 action without verifying the JWT and matching your stored txn.
  • Fail closed. If assess() throws, the token is empty, or verification fails, decline — don't fall through to a charge.
  • Fresh sid per 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 to declined_technical, not declined_other.
  • Send sub for 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.
Need credentials?Request an account at cardvera.io. You'll receive an aid and a primary/secondary key pair to drop into the Quickstart module.