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:
/v1/capabilitiesno key neededcurl 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.
| Role | Can |
|---|---|
operator | Screen payments, read decisions, resolve reviews, report abuse |
reviewer | Read decisions and resolve held ones — no screening |
analyst | Read decisions and metrics, simulate a policy change — no writes |
policy_approver | Decide pending policy change requests, and nothing else |
/v1/access-tokensowner key onlycurl -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.
/v1/screen-transaction-acpcurl -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
}
| Answer | What it means |
|---|---|
| APPROVE | Nothing in your policy objects. No reason is invented to look thorough. |
| HOLD | A person should look. A review case is opened and its id is returned. |
| REJECT | The 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.
| Rail | Endpoint | State |
|---|---|---|
| ACP — Stripe, OpenAI | /v1/screen-transaction-acp | enabled |
| AP2 v0.2 — Google | /v1/screen-transaction-ap2-v02 | experimental · off |
| x402 — Coinbase | /v1/screen-transaction-x402 | experimental · off |
| UCP | /v1/screen-transaction-ucp | experimental · off |
| MPP — Stripe, Tempo | /v1/screen-transaction-mpp | experimental · off |
| AP2 legacy | /v1/screen-transaction | compatibility · 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:
/v1/integrations/acpalso ap2, ucp, evmOne 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.
/v1/policy/v1/policyowner key| Setting | Effect |
|---|---|
per_transaction_limit | Above this, the payment is never waved through unexamined |
daily_limit_per_agent | Counted across the agent's last 24 hours, not per call |
human_hold_threshold | Above this amount, no automatic approval |
require_human_presence_above | Above this, an unattended payment raises the score |
merchant_whitelist / blacklist | Allowed and refused merchants |
currency | Limits are read in this currency only — a payment in another one is held, never converted silently |
Try a change before making it
/v1/policy/simulateReplays 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
/v1/policy/change-requests/v1/policy/change-requests/{id}/decisionpolicy_approverA 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.
/v1/policy/versionsEvery 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.
/v1/reviews?status=PENDING/v1/reviews/{id}/resolvecurl -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.
/v1/transactions/{id}/evidenceNot 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
/v1/transactions/evidence-export?limit=1000add &fingerprints=true when you need themFingerprints 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.
| Exit | Meaning |
|---|---|
0 | Match. The record supports the decision filed against it. |
1 | Mismatch, with the differences printed. This is the case the whole feature exists for. |
2 | Not 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.
/v1/transactions/{id}/outcomecurl -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"}'
| Outcome | Means |
|---|---|
legitimate | The payment was genuine |
fraudulent | It turned out to be fraud |
disputed | A chargeback or dispute followed |
not_executed | The 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.
/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_coveragefirst. A fine accuracy over 2% coverage means very little. - A rate with no denominator is
null, not0. Zero would read as a perfect score earned on no evidence. synthetic_outcomescounts 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.
/v1/commerce/sandbox/eventscurl -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"
}'
/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.
| Status | Means |
|---|---|
| consistent | The records you supplied agree. Not proof of settlement, and not proof your provider's history is complete. |
| incomplete | Something required was absent. This is never a pass. |
| anomaly | A named disagreement, reported alongside anything also missing. |
| Finding | In plain terms |
|---|---|
REFUND_WITHOUT_CAPTURE | Money went out. Nothing ever came in. |
REFUND_EXCEEDS_CAPTURE | More was refunded than was ever collected. |
PAYMENT_TOTAL_MISMATCH | The amount taken is not the amount agreed. |
ORDER_TOTAL_MISMATCH | The order was written for a different amount. |
DUPLICATE_OPERATION_ACROSS_SOURCES | Two systems claim the same payment. We refuse to add them together. |
CONFLICTING_EVENT | The same event arrived twice carrying different values. |
CURRENCY_MISMATCH | Amounts in different currencies. We do not convert them behind your back. |
CONFLICTING_OPERATION | The same payment or refund was reported twice, for different amounts. Nothing here can decide which report is the true one. |
AMBIGUOUS_CHECKOUT_TOTAL | Two basket totals for one order — so no comparison against it is safe. |
AMBIGUOUS_ORDER_TOTAL | Two 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.
/v1/webhookHTTPS onlyThe secret is returned once. Every delivery is signed with it, so you can tell our call from anyone else's.
/v1/webhook-deliveriesThe outbox: what was sent, how many attempts, what your endpoint answered. When a delivery fails you can see why instead of guessing.
/v1/webhook/test-receiverA 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.
/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.
/v1/data-retention1 to 3650 days/v1/data-export/v1/data-purgeRetention 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.
/v1/retention-holds/v1/audit-eventsEvery 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
| Status | What happened |
|---|---|
401 | Key missing, invalid or deactivated. The response carries a WWW-Authenticate challenge naming the header we expect. |
403 | The key is valid but the role cannot reach this endpoint. |
404 | Not found — including records that belong to another account, which are never distinguished from records that do not exist. |
422 | The body did not match the schema. The response names the field. |
429 | 60 requests per minute per key, on a sliding window shared across our servers. |
503 | A 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
/v1/healthno key needed/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.