← Card Data Desk / API
Tokens

Drive Card Data Desk from your own code

The page does its free work in the browser: it turns six answers into the PCI DSS v4.0.1 SAQ each channel points at and the merchant level, scans the pasted code, config, page markup, schema and logs with 23 rules (PC-01..PC-23: card numbers, track data, card codes, logs, hashes, TLS, card fields, payment-page scripts, secrets), and masks card numbers, track data, card-code values and secrets. Its two paid lanes are available over HTTP. You send one JSON object with task set to "scope" or "review", and the model replies with one JSON object.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform holds the estimate's hold_credits while the run is in flight and then charges only what it used (charged_credits). /estimate is free. A plain-text summary is at /llms.txt.

The response envelope

Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.

Error codes

HTTPcodemeaning
400VALIDATION_ERRORThe body is not a JSON object, or a field is the wrong type. Every field of the run input is a string.
401UNAUTHORIZEDNo token, or an expired one: get a new one (step 2).
402INSUFFICIENT_CREDITSThe balance is below the run's minimum. Top up at skillsafe.ai/account/billing/.
403FORBIDDENA guest token tried a metered run: sign in for a personal token.
404NOT_FOUNDUnknown job id.
429RATE_LIMITEDBack off and retry with the same Idempotency-Key.

The task field: two lanes

taskwhat the model does
"scope"Reads the SAQ, the level, the systems in scope and why, how card data flows, the requirements that carry the weight and how to shrink the scope; answers every scope flag. Runs from the answers or a description alone; code may be empty.
"review"Reviews the masked paste: rates storage, transmission, software, access and logging, gives findings with exact before/after edits, answers every flag. Needs code.

Input fields (all strings)

fieldcontent
taskrequiredscope or review
factsrequiredA JSON string: answers, saq (the page's SAQ reading: saq, channels, level, validation, conflicts, saq_if_paste, notes), files (path, kind, paste lines), components, flags (uid, id, sev, req, area, file, line, text, detail), other_flags (scope lane), masked, notes. It is PCI.facts(A, answers, lane) in pcikit.js.
codeoptional for scopeThe paste as the page sends it: file-marker lines between files, card numbers cut to the first six and last four, track data, card-code values and secrets replaced. Mask your own paste the same way before sending it.
descriptionoptionalHow payments work, in your words (at most 8,000 characters).
noteoptionalA goal or constraint (at most 2,000 characters).
scope_handoffreview onlyA JSON string of a scope reply's saq, level, headline, components and data_flows.
retry_noteoptionalSent only when a previous reply could not be read.

The output contracts

task: "scope"

headline, saq (A, A-EP, B, B-IP, C-VT, C, P2PE, SPoC, D-Merchant, D-SP, multiple, undetermined), saq_agrees, saq_reason, level (1-4, SP1, SP2, unknown), level_note, channels[{channel, saq, why}], components[{name, category: cde|connected|security|out_of_scope|third_party, why, evidence}], data_flows[{step, data: PAN|PAN+SAD|token|masked|none, where}], requirements[{req, applies: yes|no|check, why, evidence}], reductions[], flag_answers[{flag, status: confirmed|dismissed, reason}], questions[], summary.

task: "review"

verdict (blocked, needs_work, ready), headline, system, areas[{area, status: sound|fragile|broken, note}], findings[{id, flags, req, file, line, severity, title, why, evidence, edits[{before, after}]}], flag_answers, plan[], questions[], summary.

Worked examples

These are the page's own bundled examples (invented businesses), with the input as app.js builds it and the saved model reply the page replays for free.

Scope: a candle shop on an iframe online and a virtual terminal by phone

Input (facts and code shortened here):

{
 "task": "scope",
 "facts": "(a JSON string; decoded, its saq is multiple, level 3, with 0 flags)",
 "code": "<!-- file: site/checkout.html -->\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>Checkout - Brackwillow Candle Co.</title>\n  <script src=\"https://js.stripe.com/v3/\"></script>\n  <script src=\"https://widget.chatbubble.example.net/loader.js\" async></script>\n  <script src=\"https://cdn.metricsly.example.net/tag.js\"></script>\n  <script src=\"/js/checkout.js\" defer></script>\n</head>\n<body data-stripe-key=\"publishable-key-from-template\">\n  <h1>Checkout</h1>\n  <p id=\"order-summary\"></p>\n  <form id=\"payment-form\">\n    <div id=\"payment-element\"></div>\n    <button id=\"submit\" typ... (2549 characters in all)",
 "description": "Brackwillow Candle Co. sells hand-poured candles online and by phone. The website is our own Node/Express app on a rented VPS; the checkout page uses Stripe's Payment Element, and we never see card numbers online. The checkout page also loads a live-chat widget and our analytics tag.\n\nPhone orders: staff write the order on a paper slip, then type the card number into the virtual terminal in the Stripe Dashboard on the office laptop. The same laptop is used for email and the shop's bookkeeping. Slips are kept in a drawer and shredded at the end of each month.\n\nWe do not keep card numbers in any system. About 31,000 Visa online orders and 7,000 Visa phone orders a year.",
 "note": "Our bank sent the annual PCI letter. Which SAQ do we fill in, and is there anything we should change first?"
}

Reply:

{
 "task": "scope",
 "headline": "SAQ is multiple - SAQ A for the e-commerce iframe, but the phone channel doesn't actually qualify for C-VT yet - and you're Level 3 on 31,000 Visa e-commerce transactions a year.",
 "saq": "multiple",
 "saq_agrees": false,
 "saq_reason": "Ecommerce checks out as facts computed it: the checkout page only mounts Stripe's Payment Element into an iframe (site/checkout.html:16, site/js/checkout.js:29), so SAQ A holds. But facts.saq's own condition for the phone channel's SAQ C-VT is that the virtual-terminal computer is 'used only for that, is isolated from your other systems' - and the description says 'The same laptop is used for email and the shop's bookkeeping.' That's new information the answers didn't carry, and it directly fails the condition. As described today, the phone channel doesn't meet SAQ C-VT's eligibility and should be scoped as SAQ D-Merchant until that laptop is dedicated to the virtual terminal alone. Your own notes already flag that SAQ D-Merchant is the one SAQ that would cover the whole mix - that's a real option here, not just a fallback.",
 "level": "3",
 "level_note": "31,000 Visa e-commerce transactions a year falls in Visa's 20,000-1,000,000 range for Level 3; your acquirer assigns the final level, and the 7,000 phone orders don't change the e-commerce count that set it.",
 "channels": [
  {
   "channel": "ecommerce",
  
... (shortened here; the page shows it whole)

Review: an outdoor shop whose own server takes the card

Input:

{
 "task": "review",
 "facts": "(a JSON string; decoded, its saq is D-Merchant, level 3, with 23 flags)",
 "code": "<!-- file: checkout/templates/pay.html -->\n<form action=\"/pay\" method=\"post\">\n  <input type=\"hidden\" name=\"amount\" value=\"{{ amount }}\">\n  <input type=\"text\" name=\"card_number\" autocomplete=\"cc-number\" placeholder=\"Card number\">\n  <input type=\"text\" name=\"exp\" autocomplete=\"cc-exp\" placeholder=\"MM/YY\">\n  <input type=\"text\" name=\"cvv\" autocomplete=\"cc-csc\" placeholder=\"CVV\">\n  <button type=\"submit\">Pay</button>\n</form>\n# file: checkout/app.py\nimport hashlib\nimport logging\n\nimport requests\nfrom flask import Flask, render_template, request\n\nfrom .db import Payment, db\n\napp = Flask(__name__)\nlog =... (2436 characters in all)",
 "description": "Skerrabrae Outfitters' web shop: our own Flask checkout takes the card on our page, posts it to our server, and the server charges it through Quayrelay's API. We keep the payment record for refunds and chargebacks.",
 "note": "We are moving to a processor-hosted checkout next quarter. What has to change right now?"
}

Reply:

{
 "task": "review",
 "verdict": "blocked",
 "headline": "Blocked: your Flask server builds the raw card number and CVV into the Quayrelay request itself (app.py:31), and the database and the log already keep the PAN and CVV in the clear.",
 "system": "checkout/templates/pay.html posts a card_number/exp/cvv form straight to your own Flask /pay handler in checkout/app.py, which forwards the raw values to Quayrelay's charge API over http:// with certificate checks off, logs the card number and CVV twice, and writes both into a Payment row in checkout/db.py; logs/checkout.log shows three real transactions where this already happened.",
 "areas": [
  {
   "area": "storage",
   "status": "broken",
   "note": "app.py:36 and db.py:47-48 keep the CVV and the full card number in the Payment row; app.py:35's fingerprint is an unkeyed SHA-1 of the PAN; logs/checkout.log:66-69 already hold the live card number and CVV for three transactions."
  },
  {
   "area": "transmission",
   "status": "broken",
   "note": "GATEWAY_URL is http:// (app.py:20), the Quayrelay call disables certificate verification (app.py:33), and nginx.conf:55 still allows TLSv1 and TLSv1.1."
  },
  {
   "area": "software",
   "status": "broken",
   "note": "pay.html:4-6 carries its own card_number/exp/cvv fields and app.py:31 builds them into the Quayrelay request itself, so your server - not a hosted field - handles t
... (shortened here; the page shows it whole)

Review: a yoga studio on Stripe Checkout that is ready

Input:

{
 "task": "review",
 "facts": "(a JSON string; decoded, its saq is A, level 4, with 0 flags)",
 "code": "<!-- file: app/templates/pricing.html -->\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self'; form-action 'self' https://checkout.stripe.com\">\n  <title>Class passes - Vellmarsh Yoga Studio</title>\n  <link rel=\"stylesheet\" href=\"/static/site.css\">\n</head>\n<body>\n  <h1>Class passes</h1>\n  <form action=\"/buy/ten-class\" method=\"post\">\n    <input type=\"hidden\" name=\"csrf_token\" value=\"{{ csrf_token() }}\">\n    <button type=\"submit\">Ten-class pass - go to checkout</button>\n  </form>\n  <form actio... (2332 characters in all)",
 "description": "Vellmarsh Yoga Studio sells class passes on its website. The \"go to checkout\" buttons create a Stripe Checkout session and redirect to Stripe's own payment page; a webhook marks the pass paid. The front desk takes cash only. About 4,000 Visa card payments a year, all online.",
 "note": "Quick check before we renew our SAQ A."
}

Reply:

{
 "task": "review",
 "verdict": "ready",
 "headline": "Ready: Stripe Checkout keeps every card field off Vellmarsh's servers and the webhook signature is verified - no PCI findings to fix.",
 "system": "The Flask app redirects both class-pass purchases to a Stripe Checkout Session (app/payments.py:35-41); Stripe hosts the entire payment page and the merchant's server never receives a PAN. A signature-verified webhook (lines 44-53) reads only the Stripe session id to mark the pass paid.",
 "areas": [
  {
   "area": "storage",
   "status": "sound",
   "note": "Nothing in this paste stores card data: payments.py only ever holds a Stripe session id and a plan/price/quantity, and there is no database or model code in the paste at all."
  },
  {
   "area": "transmission",
   "status": "sound",
   "note": "nginx.conf allows only TLSv1.2/TLSv1.3 (line 66) with HSTS set (line 67). The proxy_pass to 127.0.0.1:8000 (line 69) is plain http, but that hop never carries card data under this SAQ A redirect flow - Stripe holds the PAN end to end, and the app only ever exchanges a plan name, a Stripe session id and a webhook event."
  },
  {
   "area": "software",
   "status": "sound",
   "note": "pricing.html's two forms (lines 12-19) post only a CSRF token to /buy/<plan>; there is no card field on the page. payments.py creates the Checkout Session server-side and redirects the browser to Stri
... (shortened here; the page shows it whole)

The page re-checks the reply; the API does not

The page holds every reply to account (recon.js): the SAQ against its reading of the answers, the level against the counts, quotes against the description and paste, every finding's file, line and evidence, every edit applied in order and the scan run again, every flag answered once, and no unmasked card number. Over the API you get the model's reply as it is; run the same checks yourself, or paste the reply's input into the page.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://pci-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"pci-desk"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.

# input.json: the run input object - {"task": "scope", "facts": "...", "code": "...", "description": "..."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses pci-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="pci-desk:scope:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream (review)

The same run as Server-Sent Events. review.json is a review input, with scope_handoff when it follows a scope reading.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: pci-desk:review:$(shasum -a 256 review.json | cut -c1-16):a1" --data @review.json