Technical guide Product Discuss your integration Back to site

Technical guide

TrustedPAI is an independent checking service for payments. It answers two questions. Before a payment happens: should this go through, and why. After it happens: do your own systems actually agree that it did.

It is an HTTP API. It never moves money, never settles, never refunds, and never blocks anything by itself. It returns an answer and a record; what you do with them is yours.

Who we are

An independent service, tied to no payment provider and no marketplace. We are not your processor and we do not compete with it: we sit beside whatever you already run, and we are paid by you rather than by a share of what you move.

What that independence buys you

Nobody grades their own homework. When a payment provider tells you its records are consistent, it is checking itself. We compare what several systems say and have no stake in which of them turns out to be right.

Start where your problem is

Most people arrive with one question, not all of them. Pick yours.

Before the payment

An AI agent is about to pay on someone's behalf. You send us the payment authorization it presented. We check the cryptography, apply the policy you configured, and return approve, hold or reject with the reasons written out. Typical answer in well under a second.

After the payment

Your checkout, your payment provider and your back office each keep their own record. Send us what they say and we tell you where they disagree — a payment with no order behind it, a refund with no payment, the same capture claimed twice.

The part that is unusual. Every decision we return can be reproduced by you, on your machine, offline, without asking us for anything. That is not a report you have to believe. See Checking us yourself.

Your first call

Base URL and one header. There is no SDK to install to get started.

# everything lives under this origin
https://api.trustedpai.com

# every authenticated call carries your key in this header
X-API-Key: tpai_live_...

Check you can reach the service and see which rails your account can call:

GET/v1/capabilitiesno key needed
curl https://api.trustedpai.com/v1/capabilities
{
  "service": "TrustedPAI",
  "api_version": "v1",
  "protocols": [
    { "id": "acp", "status": "primary", "enabled": true,
      "screening_endpoint": "/v1/screen-transaction-acp" },
    // the other rails, each with its own status
  ]
}

Your API key is created once and shown once. It is never retrievable afterwards, not from us and not from any console — if it is lost, a new one is issued and the old one is revoked.

Keys, tokens and roles

One owner key per account. Everything your team touches day to day should use a restricted token instead.

The owner key can do everything, including creating and revoking tokens. Each token carries a role, and the role decides which endpoints answer it.

RoleCan
operatorScreen payments, read decisions, resolve reviews, report abuse
reviewerRead decisions and resolve held ones — no screening
analystRead decisions and metrics, simulate a policy change — no writes
policy_approverDecide pending policy change requests, and nothing else
POST/v1/access-tokensowner key only
curl -X POST https://api.trustedpai.com/v1/access-tokens \
  -H "X-API-Key: $OWNER_KEY" -H "Content-Type: application/json" \
  -d '{"label":"risk-ops","role":"reviewer","expires_in_days":90}'

The token comes back once in the response. Copy it then.

The separation that matters. A policy_approver cannot change the policy — it can only approve a change someone else proposed. That split exists so no single credential can both loosen a limit and wave it through.

Screening a payment

One call, before you execute. You keep the final decision.

Every rail has its own endpoint because every rail proves authorization differently, but they all return the same answer shape. This example uses ACP, the rail enabled today.

POST/v1/screen-transaction-acp
curl -X POST https://api.trustedpai.com/v1/screen-transaction-acp \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "spt_id": "spt_1QeX...",
    "merchant_id": "merchant-a",
    "agent_id": "agent-77",
    "amount": 120.00,
    "currency": "EUR"
  }'
{
  "client": "Acme Payments",
  "recommendation": "HOLD",
  "risk_score": 55,
  "reasons": [
    "Amount 120.00 EUR exceeds the per-transaction limit (100.00 EUR)",
    "No human user present for a transaction of 120.00 EUR (threshold 50.00 EUR)"
  ],
  "processing_time_ms": 84.2,
  "transaction_id": 4471,
  "review_case_id": 218
}
AnswerWhat it means
APPROVENothing in your policy objects. No reason is invented to look thorough.
HOLDA person should look. A review case is opened and its id is returned.
REJECTThe authorization failed verification, or the risk is high enough that your policy would not allow it.

reasons is the whole point. Every line is a rule that fired, written so it can be shown to a customer or an auditor without a translation layer.

Replay is refused, not detected later. The identifiers in the authorization are claimed atomically. If the same authorization arrives twice at the same moment, one of them loses the race and is rejected — rather than both being approved and reconciled afterwards.

The six payment rails

Same answer, different proof. Only what your account has enabled will answer.

One rail answers on the shared service today. The others are implemented and switched off, and are enabled per account on request. The service states this itself, without a key, at /v1/capabilities — if this table and that endpoint ever disagree, the endpoint is right.

RailEndpointState
ACP — Stripe, OpenAI/v1/screen-transaction-acpenabled
AP2 v0.2 — Google/v1/screen-transaction-ap2-v02experimental · off
x402 — Coinbase/v1/screen-transaction-x402experimental · off
UCP/v1/screen-transaction-ucpexperimental · off
MPP — Stripe, Tempo/v1/screen-transaction-mppexperimental · off
AP2 legacy/v1/screen-transactioncompatibility · off

Experimental is our own word for a rail that is built and tested but has not met the conditions we set for promoting it: an independent interoperability run against a counterparty, and a latency measurement on a real deployment. Both are outstanding for all four. Asking for one switches it on for your account; it does not change what has been established about it. Compatibility means the rail is kept working for integrations written against it, and is not where a new one should start.

Each rail needs its verification material registered once, so screening never fetches a key from a URL the counterparty controls:

PUT/v1/integrations/acpalso ap2, ucp, evm

One rail is honest about its limits. MPP card mode carries a payload encrypted toward the merchant's own provider. Nobody outside that provider can verify it — the specification says so. We run structural checks, mark the result as not cryptographically verified, and the risk engine scores it accordingly. It is never presented as equivalent to the others.

Your policy

The limits are yours. We apply them; we do not invent them.

GET/v1/policy
PATCH/v1/policyowner key
SettingEffect
per_transaction_limitAbove this, the payment is never waved through unexamined
daily_limit_per_agentCounted across the agent's last 24 hours, not per call
human_hold_thresholdAbove this amount, no automatic approval
require_human_presence_aboveAbove this, an unattended payment raises the score
merchant_whitelist / blacklistAllowed and refused merchants
currencyLimits are read in this currency only — a payment in another one is held, never converted silently

Try a change before making it

POST/v1/policy/simulate

Replays your recent decisions against a proposed policy and reports how many would have come out differently. You see the cost of a change before your customers do.

Four eyes on a change

POST/v1/policy/change-requests
POST/v1/policy/change-requests/{id}/decisionpolicy_approver

A proposal records a snapshot of the policy it was made against. If the live policy moved in the meantime, the approval does not silently apply to something else.

GET/v1/policy/versions

Every change is versioned with who made it and when, so a decision taken last March can be read against the policy that was actually in force then.

Human review

HOLD is not a dead end. It is a queue with a name attached.

GET/v1/reviews?status=PENDING
POST/v1/reviews/{id}/resolve
curl -X POST https://api.trustedpai.com/v1/reviews/218/resolve \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "decision": "APPROVE",
    "resolution_reason": "Called the customer, purchase confirmed",
    "resolved_by": "risk-ops"
  }'

A resolution without a reason is refused. Months later the question is never what was decided — it is why, and by whom.

Decision evidence

Every decision keeps the complete set of inputs it was made from.

GET/v1/transactions/{id}/evidence

Not a summary of the decision — the inputs themselves:

  • the result of the cryptographic verification, and why it passed or failed
  • the payment as we normalised it: amount, currency, merchant, whether a human was present
  • the policy exactly as it stood at that moment
  • the agent's history as it was read, not as it is now
  • the instant the decision was taken, and the version of the rules that took it
GET/v1/transactions/evidence-export?limit=1000add &fingerprints=true when you need them

Fingerprints are off by default on the bulk export. Hashing is cheap for one record and not cheap for a thousand — you should not pay for it while paging through history.

Checking us yourself

You do not have to believe our answer. Reproduce it.

The rules are a pure function of the five inputs above. Given the same inputs, the same answer comes out — so the check runs on your machine, against a file, with no database, no network and no key.

curl "https://api.trustedpai.com/v1/transactions/4471/evidence" \
  -H "X-API-Key: $KEY" > evidence.json

python scripts/evidence/verify_decision_evidence.py evidence.json
Transaction:  4471
Recorded:     HOLD, risk 55
Fingerprint:  b6cf7aca0a6a930f...

MATCH — the recorded decision is the decision these inputs produce.
ExitMeaning
0Match. The record supports the decision filed against it.
1Mismatch, with the differences printed. This is the case the whole feature exists for.
2Not checkable — recorded under a different rule version, or before we stored everything the engine reads. Reported as unverifiable, never as wrong.

Why the third case is not swept under the rug. Calling an old record wrong would accuse a decision that may well have been right. The tool says it cannot judge, and says why.

Telling us what happened

You report a fact about the payment. You never grade our decision.

PUT/v1/transactions/{id}/outcome
curl -X PUT https://api.trustedpai.com/v1/transactions/4471/outcome \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"outcome":"fraudulent","notes":"Chargeback received 12 days later"}'
OutcomeMeans
legitimateThe payment was genuine
fraudulentIt turned out to be fraud
disputedA chargeback or dispute followed
not_executedThe payment never happened at all

Whether our recommendation was right is derived by comparing the two. That is deliberate: an account cannot mark its own decision quality. Reporting again for the same transaction corrects the record rather than adding a second one, and the correction is written to the audit trail.

not_executed is excluded from the measurement. A payment we blocked that then never happened proves nothing either way. Counting it as a success would flatter every rejection we ever made.

Decision quality

Rates always come with the number they were computed from.

GET/v1/decision-quality
{
  "outcome_coverage": 0.31,
  "approved_and_labelled": 160,
  "false_negatives": 23,
  "false_negative_rate": 0.1437,
  "blocked_and_labelled": 30,
  "false_positives": 7,
  "false_positive_rate": 0.2333,
  "synthetic_outcomes": 0
}
  • Read outcome_coverage first. A fine accuracy over 2% coverage means very little.
  • A rate with no denominator is null, not 0. Zero would read as a perfect score earned on no evidence.
  • synthetic_outcomes counts demonstration data. When it equals the labelled total, none of the figures above rest on a real payment — and you can see that rather than being told a shaped number.

Commerce reconciliation

Send what each of your systems says. We tell you where they disagree.

One observation at a time, each carrying which system it came from, which operation it describes, and the amount in minor units.

POST/v1/commerce/sandbox/events
curl -X POST https://api.trustedpai.com/v1/commerce/sandbox/events \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{
    "journey_id": "order-4471",
    "event_id": "evt_9f3c",
    "operation_id": "cap-1",
    "kind": "payment_captured",
    "currency": "EUR",
    "amount_minor": 12000,
    "occurred_at": "2026-09-10T14:22:05Z"
  }'
GET/v1/commerce/sandbox/journeys/{journey_id}
{
  "journey_id": "order-4471",
  "result": {
    "status": "anomaly",
    "findings": ["REFUND_WITHOUT_CAPTURE"],
    "missing": ["payment_captured"]
  }
}

The same event arriving twice counts once. Amounts stay exact integers throughout — no floating point ever touches money.

See it running on three orders, no key required.

What a finding means

Three statuses, and a named reason whenever something is wrong.

StatusMeans
consistentThe records you supplied agree. Not proof of settlement, and not proof your provider's history is complete.
incompleteSomething required was absent. This is never a pass.
anomalyA named disagreement, reported alongside anything also missing.
FindingIn plain terms
REFUND_WITHOUT_CAPTUREMoney went out. Nothing ever came in.
REFUND_EXCEEDS_CAPTUREMore was refunded than was ever collected.
PAYMENT_TOTAL_MISMATCHThe amount taken is not the amount agreed.
ORDER_TOTAL_MISMATCHThe order was written for a different amount.
DUPLICATE_OPERATION_ACROSS_SOURCESTwo systems claim the same payment. We refuse to add them together.
CONFLICTING_EVENTThe same event arrived twice carrying different values.
CURRENCY_MISMATCHAmounts in different currencies. We do not convert them behind your back.
CONFLICTING_OPERATIONThe same payment or refund was reported twice, for different amounts. Nothing here can decide which report is the true one.
AMBIGUOUS_CHECKOUT_TOTALTwo basket totals for one order — so no comparison against it is safe.
AMBIGUOUS_ORDER_TOTALTwo order totals for one order. The same refusal, on the other side.

An amended basket is the usual cause of the two ambiguous findings. A journey identifies one frozen basket. If the customer changes it and the new total is recorded under the same journey, we are holding two baskets and no rule for choosing between them — so comparison stops rather than picking one. That is a refusal to guess, not a discrepancy in your data: every other check on the journey still runs, and refund coverage is still evaluated against what was actually captured.

Record each revision under its own journey identifier. The two are then compared separately, and each one answers for itself.

Webhooks

Signed, retried, and inspectable when they fail.

PUT/v1/webhookHTTPS only

The secret is returned once. Every delivery is signed with it, so you can tell our call from anyone else's.

GET/v1/webhook-deliveries

The outbox: what was sent, how many attempts, what your endpoint answered. When a delivery fails you can see why instead of guessing.

GET/v1/webhook/test-receiver

A disposable endpoint of ours, so you can watch a full signed delivery arrive before pointing us at your own systems.

Usage

Counted when the decision is taken, never used to refuse one.

GET/v1/usage?months=12
{
  "current_period": "2026-09",
  "current_period_events": 2500,
  "monthly_event_allowance": 10000,
  "allowance_remaining": 7500,
  "allowance_exceeded": false
}

The count is written in the same database transaction as the decision, so it survives the deletion of the decisions themselves under your retention setting. Going over an agreed volume is a conversation, not an outage: nothing is ever refused because of this figure.

Your data

You set how long we keep it, and you can take it out at any time.

PUT/v1/data-retention1 to 3650 days
GET/v1/data-export
POST/v1/data-purge

Retention runs automatically at the interval you choose. A hold pins specific records against it — a dispute under investigation should not be deleted by a schedule.

POST/v1/retention-holds
GET/v1/audit-events

Every configuration change, policy edit, review resolution and purge is recorded with the actor who did it.

We never receive card numbers, payment credentials or the raw mandates themselves. What we hold is what we were sent to screen, plus the decision.

Errors and limits

StatusWhat happened
401Key missing, invalid or deactivated. The response carries a WWW-Authenticate challenge naming the header we expect.
403The key is valid but the role cannot reach this endpoint.
404Not found — including records that belong to another account, which are never distinguished from records that do not exist.
422The body did not match the schema. The response names the field.
42960 requests per minute per key, on a sliding window shared across our servers.
503A dependency we need is unavailable. We fail closed: you get no answer rather than a cheerful one.

We would rather return nothing than return an approval we cannot stand behind. Where a control cannot run, the answer is an error or a hold, never a pass.

Service status

GET/v1/healthno key needed
GET/v1/readychecks the database and the shared store

/v1/health answers if the process is up. /v1/ready answers only if the things it needs to give a real answer are reachable — use that one for your monitor.

A public page lives at api.trustedpai.com/status, and the full OpenAPI document at /docs.