API reference
Send us everything your own system holds: payments, the people behind them, their accounts and wallets, Travel Rule transfers, screening you did yourself, and the documents. Ask us about a payment you have not released and the answer is allow, hold or block. Read back what the rules raised and what is waiting on a person.
Authentication
Every request carries a bearer key, issued in the workspace under Integrations, API access. Keys are stored hashed and can be revoked at any time.
A key writes to one book. A key beginning prv_test_ writes to your sandbox book instead: same endpoints, same rules, none of it in your real records.
Three hundred requests a minute by default. Every response says how many you have left.
/api/v1/pingCheck the key and the book it writes to
key is the name you gave the key. book is the reporting entity it writes to. If the book is wrong, the key is wrong.
Request
curl https://app.pruvyo.com/api/v1/ping \ -H "Authorization: Bearer prv_live_…"
Response
{"ok":true,"key":"Core banking","book":"Acme Payments UAB"}/api/v1/transactionsRecord what already happened
Up to 500 rows a request. They land on the same ledger, through the same engine, into the same alert book as a file import.
The answer names every alert the rules raised and points each one at the row that caused it. A rejected row never stops the good ones in the same request.
Fiat and crypto go through this one endpoint.
| Field | Type | Meaning |
|---|---|---|
occurred_atrequired | string | ISO 8601. Also accepts date or timestamp. Refused if more than a day ahead. |
amountrequired | number | string | Major units. 1.250,50 and 1,250.50 both parse. Or amount_minor as an integer. |
reference | string | Your id. Send it and a retry answers duplicate instead of doubling your ledger. Also accepts external_ref or id. |
currency | string | Three letters. EUR by default. A crypto ticker here is read as a quantity of that asset, not as money. |
direction | in | out | Omit it and the sign of the amount decides. |
customer | string | The person or company on your side of the payment. |
counterparty | string | Who was on the other side. |
counterparty_country | string | Two-letter country of the counterparty. |
asset | string | For crypto. The ticker moved. |
tx_hash | string | For crypto. Marks the row on-chain. |
counterparty_address | string | For crypto. Sanctions screening needs it: without it the sanctions rules report themselves not evaluated for that payment rather than passing it. |
payment_type, rail | string | How it moved, where your own vocabulary has one. |
account | string | Which of your accounts the payment came from. Also accepts account_ref or account_id. Send it and a customer identified weeks later attributes these rows backwards. |
Request
curl -X POST https://app.pruvyo.com/api/v1/transactions \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"transactions":[{
"reference": "your-id-0001",
"occurred_at": "2026-08-01T09:30:00Z",
"amount": 88000.50,
"currency": "EUR",
"direction": "in",
"customer": "Yuki Tanaka",
"counterparty": "Nordbank AG",
"counterparty_country": "DE"
}]}'Response
{"batch_id":"b_3f9a…",
"received":1,
"imported":1,
"duplicates":[],
"rejected":[],
"unmatched_customers":[],
"ambiguous_customers":[],
"alerts":[{"index":0,"ref":"AL-2291","rule":"Large inbound from a new counterparty","severity":"high"}],
"engine":{"rules_evaluated":34,"unmeasured":[]}}/api/v1/customersThe people and companies behind the payments
A customer is the identity everything else joins on, so the reference is what makes a payment and a wallet belong to the same person.
| Field | Type | Meaning |
|---|---|---|
referencerequired | string | Your id for them. Everything else joins on it. |
namerequired | string | What they are called. |
type | person | company | Person by default. individual, retail and natural also read as person; business, corporate, legal and entity read as company. |
riskrequired | string | Your own rating. An unknown word is refused rather than rounded to something. |
country | string | Two letters. |
kyc_state | string | Where their verification stands. |
onboarded_at | string | ISO 8601. |
expected_monthly_eur | number | What you expect them to move, so the rules can notice when they do not. |
Request
curl -X POST https://app.pruvyo.com/api/v1/customers \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"customers":[{
"reference": "cus-4471",
"name": "Yuki Tanaka",
"type": "person",
"country": "JP",
"risk": "medium"
}]}'/api/v1/walletsThe addresses those customers hold
Control of a wallet is proved by a signed message and nothing else. Send the proof and the wallet lands verified; send it without and it lands declared.
| Field | Type | Meaning |
|---|---|---|
customerrequired | string | The customer reference this address belongs to. |
addressrequired | string | The address itself. |
chainrequired | string | BTC, ETH, TRON or SOL. Anything else is refused rather than guessed at. |
label | string | What your team calls it. |
custodial | boolean | True where a provider holds the keys, so it lands as custody rather than as theirs. |
proof | object | message and signature. A message naming the address, signed by it. |
Request
curl -X POST https://app.pruvyo.com/api/v1/wallets \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"wallets":[{
"customer": "cus-4471",
"chain": "ETH",
"address": "0x9f2c…",
"proof": {"message": "I control 0x9f2c… for Pruvyo", "signature": "0x…"}
}]}'/api/v1/accountsThe accounts their money sits in
Declare an account so the payments that name it attribute to somebody. Send the customer first: an account belonging to nobody is a row that never joins, and it is refused by naming the customer rather than the account.
Venue and reference together are the identity, so the same account sent twice updates rather than doubles.
| Field | Type | Meaning |
|---|---|---|
customerrequired | string | The customer reference this account belongs to. |
venuerequired | string | Where it is held, as you call it: the bank, the exchange. |
referencerequired | string | IBAN, account number, or the venue's own reference. Also accepts account_ref or iban. |
label | string | What your team calls it. |
Request
curl -X POST https://app.pruvyo.com/api/v1/accounts \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"accounts":[{
"customer": "cus-4471",
"venue": "Nordbank AG",
"reference": "DE89370400440532013000",
"label": "Treasury"
}]}'/api/v1/transfersTravel Rule transfers
A transfer message is data about a payment, not the payment, so these land on the Travel Rule console and never on the ledger. Sending the payment as well is correct: they are two records of one event.
What the regulation wants and your record does not hold is worked out here, not taken from you, and comes back per transfer as incomplete. It is the whole working of the module, so a caller who could declare nothing missing would be switching off their own Travel Rule.
| Field | Type | Meaning |
|---|---|---|
referencerequired | string | Your id for the transfer. The console's reference and its de-duplication are both built off it. |
directionrequired | in | out | Also accepts incoming, outgoing, sent, received. |
occurred_atrequired | string | ISO 8601. |
amount | number | Fiat value. Leave it out for an unpriced crypto transfer: it stays a quantity rather than being read as zero and falling under every threshold. |
currency | string | Three letters. EUR by default. |
asset, asset_amount | string | The ticker and how much of it moved. |
originator | object | name, account, address, country. Who sent it. |
beneficiary | object | The same four. Who receives it. |
counterparty_vasp | string | The exchange or custodian on the other side. |
tx_hash, counterparty_address | string | The chain record, where there is one. |
Request
curl -X POST https://app.pruvyo.com/api/v1/transfers \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"transfers":[{
"reference": "tr-9001",
"direction": "out",
"occurred_at": "2026-08-30T09:30:00Z",
"amount": 12500,
"currency": "EUR",
"originator": {"name": "Yuki Tanaka", "account": "cus-4471"},
"beneficiary": {"name": "Meridian Trading", "account": "0x9f2c"}
}]}'Response
{"received":1,"landed":1,"rejected":[],
"incomplete":[{"index":0,"reference":"tr-9001","missing":["Beneficiary account"]}]}/api/v1/kyc-checksIdentity, PEP and adverse media you ran
The customer record takes a kyc_state as one word. This takes the checks behind it. The risk model reads them, so a customer whose file says verified and whose checks we never received is scoring against nothing.
Anything that did not pass must carry a reason. A match nobody can explain is noise, and noise is what gets ignored.
| Field | Type | Meaning |
|---|---|---|
customerrequired | string | The customer reference the check is about. |
providerrequired | string | Who ran it. |
kindrequired | string | identity, liveness, address, sanctions, pep or adverse_media. |
resultrequired | string | pass, fail, match or not_run. |
detail | string | Required whenever the result is not pass. Plain language. |
checked_at | string | ISO 8601. Now, if you leave it out. |
expires_on | string | When it goes stale. A passport expires on its own date; screening ages. |
Request
curl -X POST https://app.pruvyo.com/api/v1/kyc-checks \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"checks":[{
"customer": "cus-4471",
"provider": "ComplyAdvantage",
"kind": "pep",
"result": "match",
"detail": "Named on the UK consolidated list, 2019",
"expires_on": "2027-01-31"
}]}'/api/v1/screeningScreening you did yourself
If you screen an address with a provider you already pay for, send us the answer. Without it Pruvyo either screens it again, which you pay for twice, or reports the control as not evaluated on that payment.
The verdict is a boolean because a control is binary. The categories sit beside it so why survives the boolean, and raw keeps whatever your provider actually said, so a disputed verdict can be re-read years later rather than argued from a summary.
| Field | Type | Meaning |
|---|---|---|
addressrequired | string | What was screened. |
providerrequired | string | Who answered. A verdict nobody is named for is one nobody can stand behind at an inspection. |
sanctionedrequired | boolean | True or false. A missing verdict is refused rather than read as clean. |
categories | string[] | Every category they named. |
checked_at | string | ISO 8601. Now, if you leave it out. |
raw | object | Their answer, kept whole. |
Request
curl -X POST https://app.pruvyo.com/api/v1/screening \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"results":[{
"address": "0x9f2c…",
"provider": "Chainalysis",
"sanctioned": false,
"categories": ["exchange"]
}]}'/api/v1/documentsEvidence your own system holds
Multipart, because this one carries a file. Everything else here is JSON. Up to 25 MB.
The bytes are read and the checks are run before anything is said about the file, the same as a document dragged onto the screen. A file nobody has examined is never recorded as clean, and an API door is not an excuse to skip a check the screen cannot skip. The answer says what the checks found, or that they did not run and why.
The same bytes arriving twice is a re-send, not a second document: you get the id of the one already on file and nothing is added.
| Field | Type | Meaning |
|---|---|---|
filerequired | file | The document itself. |
customerrequired | string | The customer reference it belongs to. |
kindrequired | string | kyc_id, proof_address, source_of_funds, bank_statement, incorporation, screening, filing_pack or other. |
Request
curl -X POST https://app.pruvyo.com/api/v1/documents \ -H "Authorization: Bearer prv_live_…" \ -F "file=@statement.pdf" \ -F "customer=cus-4471" \ -F "kind=bank_statement"
Response
{"document_id":"doc_5b1e…",
"name":"statement.pdf",
"kind":"bank_statement",
"sha256":"9f2c…",
"size_bytes":184320,
"checks":{"ran":true,"verdict":"passed"}}/api/v1/decisionsAsk before money moves
The same shape as a transaction, plus a required reference. This asks about a payment you have not released.
The answer is allow, hold or block, with the rules that decided it named. Where the rules can answer it comes back immediately; where they cannot, it is held for a person.
One reference gets one verdict, for ever. A retry replays the stored answer rather than evaluating again, so the same payment cannot be allowed at nine and blocked a minute later. The answer says replayed so you can tell.
not_evaluated names the controls that exist but could not be measured on this payment: what an allow was not checked against.
If Pruvyo is unreachable you get an error, never a verdict. Whether that means hold or release is your policy to set.
Request
curl -X POST https://app.pruvyo.com/api/v1/decisions \
-H "Authorization: Bearer prv_live_…" \
-H "Content-Type: application/json" \
-d '{"reference": "payment-4471",
"occurred_at": "2026-08-01T09:30:00Z",
"amount": 250000,
"currency": "EUR",
"direction": "out",
"customer": "cus-4471",
"counterparty": "Meridian Trading",
"counterparty_country": "AE"}'Response
{"decision_id":"dec_8f21…",
"reference":"payment-4471",
"verdict":"hold",
"reasons":["Sanctions screening"],
"not_evaluated":[],
"review":{"ref":"HD-118","status":"pending","decided_at":null,"note":null},
"replayed":false,
"latency_ms":41,
"decided_at":"2026-08-30T09:30:01Z"}/api/v1/decisions/{id}Read the answer back
Poll it until the review answers, or register a callback below and stop polling.
Response
{"decision_id":"dec_8f21…",
"reference":"payment-4471",
"verdict":"allow",
"reasons":[],
"not_evaluated":[],
"review":null,
"replayed":true,
"decided_at":"2026-08-30T09:30:01Z"}Callbacks
Register a callback address against your key and Pruvyo posts to it the moment an analyst decides. It fires for a decision you asked for and for a hold a rule placed on a payment you reported, which are told apart by the event name.
The post carries the decision id and your own reference. No verdict, no customer, no amount: you read the result back with your key. A forged or replayed callback is therefore harmless, and there is no signature scheme for either side to get wrong.
Answer with any 2xx within five seconds. Anything else is retried until it lands, up to eight attempts, and polling keeps working the whole time.
Request
POST https://your-system.example/pruvyo/decisions
Content-Type: application/json
{"event":"decision.decided","decision_id":"dec_8f21…","reference":"payment-4471"}
{"event":"hold.decided","hold_ref":"HD-118","reference":"HD-118"}/api/v1/holdsWhat is waiting on a person
Everything held in your book, newest first, whether it was held through a decision you asked for or by a rule on a payment you reported. Before this existed, a payment you pushed could be held and there was nothing to poll.
Send the last placed_at you saw as since and a loop never re-reads what it has handled. next_since in the answer is the stamp to send next time. Up to 500 a page.
Read only. A hold is released by a person with the standing to release it, under four eyes; a key deciding one would move that authority to whoever holds the key.
Request
curl "https://app.pruvyo.com/api/v1/holds?since=2026-08-30T09:00:00Z" \ -H "Authorization: Bearer prv_live_…"
Response
{"holds":[{
"ref":"HD-118",
"status":"pending",
"verdict":null,
"reason":"Sanctions screening",
"rule":"Sanctions screening",
"customer":"cus-4471",
"amount_minor":25000000,
"currency":"EUR",
"placed_at":"2026-08-30T09:00:00Z",
"decide_by":"2026-08-30T13:00:00Z"
}],
"next_since":"2026-08-30T09:00:00Z"}/api/v1/alertsWhat the rules raised
The push response names the alerts your rows raised in that request. This is everything else: a rule that counts a week of payments, an alert from a connection's own sync, a screening result that arrived after the payment did.
reference is your own id for the payment that caused it, so an alert lands against the row you sent.
Request
curl "https://app.pruvyo.com/api/v1/alerts?since=2026-08-30T09:00:00Z&limit=100" \ -H "Authorization: Bearer prv_live_…"
Response
{"alerts":[{
"ref":"AL-2291",
"title":"Large inbound from a new counterparty",
"severity":"high",
"status":"open",
"rule":"Large inbound from a new counterparty",
"customer":"cus-4471",
"reference":"your-id-0001",
"raised_at":"2026-08-30T09:05:00Z"
}],
"next_since":"2026-08-30T09:05:00Z"}Errors
| 401 | Key missing, unknown or revoked. |
| 413 | More than 500 rows in one request. |
| 422 | Nothing readable. The answer lists each rejected row with its field and the reason. |
| 429 | Over this key's rate ceiling. Retry-After says when to come back. |
| 503 | No API configured on this deployment. |
Response
{"rejected":[
{"index":1,"field":"occurred_at","reason":"not a date we can read: \"nonsense\""},
{"index":2,"field":"currency","reason":"expected a 3-letter code, got \"EURO\""}
]}Keys are issued in the workspace under Integrations, API access. Create account, then talk to us to have it switched on.