One POST. Every PSD2 check.
Behind your API gateway, the certificate in and the verdict out: the chain against 31 EU trusted lists, OCSP or CRL, qualification as ETSI TS 119 615 defines it, the PSD2 roles, and the licence in the EBA register with the passports you asked for. Headers for your gateway, JSON for your logs, milliseconds from a warm engine.
Checked again, several times a day.
A certificate that passed this morning can be revoked by lunch, and a licence withdrawn without the certificate changing at all. Give a webhook URL with the call and the certificate goes on our list: re-validated several times a day, and you hear from us, signed, the moment its status changes.
The key, not just the certificate.
A certificate is public. Behind your gateway you only know that someone sent it. Two ways to know they hold the private key: a single-use challenge they sign, or the message signature already on the request, RFC 9421 or the Berlin Group profile. Both verified against the leaf key, in the same call.
Every answer, an audit record.
An audit id on every answer and a record kept as it was sent. Each record is hashed, signed with your account's key and bound to the one before it, and the chain is anchored every hour with rFC 3161 qualified electronic time-stamps issued by AlfaTrust Certification S.A., a Qualified Trust Service Provider on the EU Trusted Lists: legally recognised in every Member State under eIDAS Article 41, verifiable with the EU DSS validator. Printed as a sheet or exported as an evidence record, it validates with openssl alone, or by anyone at tppv.app/verify: integrity, authenticity and proof of existence. It is what RTS 2018/389 Articles 29 and 34 and PSD2 Article 68 ask a bank to show for an allow or a reject, and what the PSR keeps asking for once it applies.
- 1POST per certificate
- 3 msengine, warm
- 31trusted lists
- 24/7re-checks, several a day
- 4routes
- 1 842calls, 24 h
A1 · One call
Certificate in. Verdict out.
One POST from behind your gateway. The engine builds the chain against the 31 EU trusted lists, asks the issuer's OCSP responder or reads its CRL, decides qualification the way ETSI TS 119 615 defines it, reads the PSD2 roles from the certificate, and looks the licence up in the EBA register with the passports you asked for. The answer comes back as headers for your gateway and JSON for your logs. Ask again and it comes from cache, until you say otherwise.
One POST, the certificate as the body
PEM, a PEM chain or base64 DER, with any content type but JSON. Jurisdictions in cc name the passports the licence must cover, ISO codes, uppercase, comma separated or repeated.
curl -si -X POST "https://api.tppvalidation.com/v6?cc=SE,DE&details=true" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @qwac.pemHeaders for the gateway, JSON for the logs
Status 200 means valid. One header says what was decided, one says why when it was not. The rest is in the JSON.
HTTP/2 200
X-TPP-Internal: VFD
X-TPP-Passports: SE=PSP_AI,PSP_PI;DE=PSP_AI
HTTP/2 422
X-TPP-Internal: VLW
X-TPP-Reason: withdrawnLicense, 2026-09-30Chain and lists
The chain is built from the certificate, the CAs the trusted lists name, and the issuer's AIA. The first list-anchored certificate is the trust anchor. 31 lists, re-read around the clock, signatures verified against the LOTL.
Revocation
OCSP with a nonce first, CRL as the fallback, for every certificate below the anchor. Answers are reused only until their nextUpdate.
Qualification
ETSI TS 119 615, as the regulation defines it: the CA/QC services in the chain, their status on the date, the qualifiers, QWAC or QSeal, at issuance and at validation.
Register
The organisation identifier from the certificate against the EBA register: licence present, not withdrawn, passports for the jurisdictions you named. Withdrawn comes back with its date.
A2 · The verdict
The status code decides. The headers explain.
The answer comes to your backend from us over TLS, with nothing in between: a header in it is as trustworthy as the body. Your gateway branches on the status code and two headers. Everything else is in the JSON as well, and in headers when a proxy that cannot read a body needs it.
| Branch on | What it carries |
|---|---|
| Status code | 200 is the only yes. 422 is a certificate or licence that does not pass. 401 and 403 are about your account, 503 about an upstream we depend on. |
| X-TPP-Internal | The result code. VFM or VFD on a fresh verdict (minimal or detailed), QFM or QFD from cache. On a refusal: MPP, VML, VLW, VPM, VFA, VCT, SIG or POP. |
| X-TPP-Audit | The id of this answer's own audit record, on every authenticated answer: fresh, cached or refused. Paste it on your account page and the verdict is traced to its sources; the record is kept from the moment the answer went out. |
| X-TPP-Reason | Why, when it is not 200: missingLicense, withdrawnLicense with its date, missingPassports, the engine's sub-indication, an EBA upstream code, or the signature or possession reason. |
Also present when it applies, each a field of the JSON too: X-TPP-Entity and X-TPP-Cert (entity type from the register and from the certificate), X-TPP-Passports (SE=PSP_AI,PSP_PI;DE=PSP_AI), X-TPP-Identifier (the hash of the EBA snapshot used), X-TPP-Latest (when it ran), X-TPP-Sig and X-TPP-Pop (valid or invalid, only when asked), X-TPP-Engine and X-TPP-Version, and Server-Timing with auth, parse, cache, engine and persist.
Branch on the number
200 is the only yes. 422 is a certificate or licence that does not pass, with the reason in the headers. 401 is your bearer, 503 is an upstream we depend on. The Gatekeeper answers differently on purpose: a proxy only understands 2xx and 403, so there a failed QWAC is 403 in enforce and 200 with the verdict attached in observe. Same engine, same reason codes, two answer languages.
200 valid
401 bearer missing, malformed, expired, or account expired
403 account disabled
400 not a certificate, no PSD2 certificate in the chain,
bad jurisdiction codes, or a route that does not exist
422 MPP not a PSD2 certificate · VML no licence · VLW withdrawn
VPM no passport for those jurisdictions · VFA validation failed
VCT not qualified (test certificate?) · SIG · POP
503 EBA register or engine upstream unavailable · Retry-After: 300The record, flattened
With details=true you get the whole record; without it, three fields that name the certificate and the moment.
{
"serialNumber": "…", "organizationIdentifier": "PSDSE-FINA-556000-0000",
"entityName": "…", "entityCountry": "SE", "entityType": "PSP_AI",
"ncaData": { "ncaName": "Finansinspektionen", "ncaCountryCode": "SE", … },
"pspPassports": [ { "SE": ["PSP_AI","PSP_PI"] }, { "DE": ["PSP_AI"] } ],
"certificateRevocation": "good",
"certificateData": { "qualificationAtValidation": "QC for WSA",
"notAfter": "…", "ocsp": "…", "trustList": { … }, … },
"validationTime": "2026-10-25T09:14:02Z", "dataHash": "7c1f…",
"webhookRegistered": false
}A3 · Endpoints
Four routes. Three parameters that matter.
| Route | Does | Body |
|---|---|---|
| POST /v6 | Validates the certificate and, when asked, a message signature or a possession proof. | PEM, chain or base64 DER as the body; or JSON with pem, webhook_url, pop, http_sig. |
| POST /v6/challenge | Issues a single-use possession challenge, 120 seconds. | Empty, or bind: { client_id, purpose }. |
| GET /v6/subscriptions | Lists the certificates this account is watching, with their webhook URL. | None. |
| DELETE /v6/subscriptions/{issuer}/{serial} | Stops watching one certificate. 204, or 404 when nothing was watched. | None. |
| Query | Meaning |
|---|---|
| cc=SE,DE | The jurisdictions the licence must hold a passport for. Uppercase ISO codes, comma separated or repeated. Codes we do not know are ignored; with none left, 400. 31 codes: the EU 27, Iceland, Liechtenstein, Norway, and GB for the legacy cases. |
| details=true | The full record in the JSON, not just the three identifying fields. |
Limits: 256 KB request body, 64 KB of PEM. Unknown routes answer 400. CORS preflight is answered for Authorization, Content-Type and X-Webhook-URL.
A4 · Proof of possession
Know the caller has the key.
A certificate is public. Behind your gateway you only know that someone sent it. Two ways to know they hold the private key: a challenge they sign, or the message signature they already put on the request, RFC 9421 or the Berlin Group's older form. Both verify against the leaf key of the certificate you submit, in the same call.
A nonce, signed once
Ask for a challenge, hand the TPP the string to sign over your own channel, post certificate and signature together. The nonce lives 120 seconds and is consumed on first use.
POST /v6/challenge
{ "bind": { "client_id": "tpp-42", "purpose": "onboarding" } }
→ { "challenge_id": "01JB…", "challenge": "…", "expires_in": 120,
"tbs": "tppv-pop-v1|01JB…|<challenge>|tpp-42" }
POST /v6?cc=SE (application/json)
{ "pem": "…", "pop": { "challenge_id": "01JB…", "signature_b64": "…", "client_id": "tpp-42" } }
→ X-TPP-Pop: validThe signature already on the request
Hand us the signature your gateway received and we verify it against the leaf key: RFC 9421 or the Berlin Group legacy profile, detected when you do not say. Created within five minutes, keyid matching the certificate, digest matching the body when you give it.
POST /v6?cc=SE (application/json)
{ "pem": "…",
"http_sig": { "profile": "rfc9421", "signature": "…", "signature_input": "…",
"content_digest": "…", "created": 1792742400,
"covered": { "@method": "POST", "@path": "/psd2/v1/payments",
"content-digest": "…", "x-request-id": "…" },
"body_sha256_b64": "…" } }
→ X-TPP-Sig: valid
# algorithms: RSA PKCS#1 and PSS with SHA-256/512, ECDSA P-256 and P-384A5 · Re-checks and webhooks
Checked once by you. Then by us, several times a day.
A certificate that passed this morning can be revoked by lunch, and a licence can be withdrawn without the certificate changing at all. Give a webhook URL with the call and the certificate goes on our list: re-validated several times a day, and you hear from us the moment its status changes. Every delivery is signed.
One header on the call
Send the URL in X-Webhook-URL or as webhook_url in a JSON body, never both. https only. Registration happens only on a valid verdict, and the response tells you whether it did. The same URL posted again confirms the subscription; a new URL replaces it.
curl -s -X POST "https://api.tppvalidation.com/v6?cc=SE" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Webhook-URL: https://hooks.bank.example/tpp" \
--data-binary @qwac.pem | jq .webhookRegistered
true
GET /v6/subscriptions
DELETE /v6/subscriptions/{issuer_hash}/{serial} → 204Signed, with an id you can dedupe on
Events: cert.status_changed when a re-check flips a verdict, and webhook.subscription_created, _updated and _deleted for the subscription itself. The signature is an HMAC-SHA256 over the timestamp and the raw body, with the secret we provision for your account.
POST https://hooks.bank.example/tpp
X-Webhook-Event-Id: evt_…
X-Webhook-Delivery-Id: dlv_…
X-Webhook-Signature: t=1792742400,v1=<hex hmac-sha256(secret, "<t>.<body>")>
X-TPP-Client-ID: <your client id>
User-Agent: tpp-validator-webhooks/5.0
{ "event_id": "evt_…", "event_type": "cert.status_changed",
"created_at": "2026-10-25T17:31:04Z",
"data": { "issuer_hash": "…", "serial": "…", "entity_name": "…",
"old_status": "valid", "new_status": "invalid",
"reason": "revoked", "checked_at": "…" } }Delivery waits ten seconds for your 2xx. A Slack incoming-webhook URL gets a formatted message instead of the JSON, and no signature.
A6 · CLI, MCP, Slack and Teams
The same verdict, from a terminal, an agent or a channel.
tppv validate ./qwac.pem --cc SE,DE,NL
✔ PASSED QWAC PSDSE-FINA-556000-0000 PSP_AI 138 ms
tppv validate --url https://psd2.bank.example # the server certificate of a host
tppv drop # watch a folder, validate what lands
tppv integration --gateway nginx # a sideband rule for your proxy
tppv status · tppv config · tppv trial
{ "mcpServers": { "tppv": { "url": "https://api.tppvalidation.com/mcp" } } }
tools: validate_certificate · audit_chain · search_entity
> validate the certificate in qwac.pem for Sweden and Germany
> which PSD2 licences does Klarna hold, and in which jurisdictions?
/tppvalidate # in any channel: an ephemeral validator session, yours alone
> drop or paste the PEM, pick the jurisdictions, read the verdict in the thread
# install: ai.tppvalidation.com/slack/install
@TPP Validation validate # mention the bot in a channel or a chat, attach the certificate
> the verdict comes back as a card, with the record behind it
# included with the Microsoft Marketplace offer, or ask us
The CLI, the MCP server, the Slack app and the Teams bot all call the same API with your bearer. Nothing happens in them that you could not do with curl.
A7 · Access
A bearer, minted on your account page.
The bearer
A signed token carrying your API key and an expiry. Mint it on your account page, send it as Authorization: Bearer on every call. A new one replaces the old; the full value is shown once.
What 401 and 403 mean
401: the bearer is missing, malformed, expired, or the subscription behind it has ended. 403: the account was disabled. Neither is about the certificate.
Per environment, unlimited
A subscription covers one environment with as many certificates and calls as it needs. There is no call quota in the API. A trial runs 30 days without a card.
Webhook secret
Signed deliveries need a secret on your account. We provision it when you start subscribing; ask and it is there the same day.
A8 · Plain facts