Integrate credential
verification.
Four ways in, one verifier behind them. Pick the one that fits your app; they all speak to https://api.proofingtrust.xyz. Every example on this page is taken from our automated end-to-end tests.
Before you start
- A tenant. Your account in the client area. Everything you create lives there.
- A policy. What you ask the wallet for (a DCQL query: which credential, which attributes) and which issuers you trust: your own anchors, X.509 chains, or trust lists.
- Keys, per policy. An API key
pt_live_…is for your server only (we keep its hash, not the key). A client IDptc_live_…is public, for the browser, limited to the origins you allow (allowedOrigins). Sandbox keys start withpt_test_/ptc_test_and only work there. - Want to try first? The sandbox needs none of this.
| You want | Use |
|---|---|
| "Sign in with your wallet" in any OpenID Connect client (or behind your identity broker) | OpenID Connect |
| A ready-made button in a React app: QR, same device, Digital Credentials API | React SDK |
| Full control from your backend | REST API |
| To be told when a verification ends | Webhooks |
Log in with OpenID Connect
An OpenID Provider per tenant. Logging in is presenting a credential: we open a verification with your policy and, if it is valid, your app gets an ID Token with the attributes your client maps. Create the client in the client area (OIDC tab), which also shows your Discovery URL.
- Issuer
https://api.proofingtrust.xyz/oidc/<tenantId>, Discovery at…/.well-known/openid-configuration, keys at…/jwks. - Authorization code with PKCE
S256, nothing else. The redirect URI is compared whole, and every response carriesiss(RFC 9207). Client authentication:client_secret_basic(default),client_secret_post,private_key_jwtornone; ID Token in RS256 (default) or ES256. - A pairwise
subby default: derived from the verified issuer and the attribute your policy names as the subject, and different for each relying party (the host of the redirect URI). The same person gets the samesubin your app and a different one elsewhere. Without a subject attribute in the policy, thesubis new on every login. - The wallet on the same phone by default; a QR for another device only if you enable it on the client. Not valid, cancelled or expired:
access_denied, with no attributes. - No UserInfo, refresh token or logout: the attributes travel in the ID Token.
import * as client from "openid-client"; // 6.8.8, the RP our end-to-end test uses
const issuer = new URL("https://api.proofingtrust.xyz/oidc/<tenantId>");
// Public client (PKCE, no secret), like "sandbox-demo" on the sandbox page:
const config = await client.discovery(issuer, "<client_id>", {}, client.None());
// Confidential client: client.discovery(issuer, clientId, {}, client.ClientSecretBasic(secret))
const verifier = client.randomPKCECodeVerifier();
const state = client.randomState();
const nonce = client.randomNonce();
const url = client.buildAuthorizationUrl(config, {
redirect_uri: "http://localhost:3000/callback",
scope: "openid",
response_type: "code",
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: "S256",
state,
nonce,
});
// Keep verifier, state and nonce in the user's session and send the browser to url.
// Back on the redirect_uri (?code=…&state=…&iss=…):
const tokens = await client.authorizationCodeGrant(config, new URL(callbackUrl), {
pkceCodeVerifier: verifier,
expectedState: state,
expectedNonce: nonce,
});
const claims = tokens.claims(); // signature, iss, aud, nonce and exp checked against the issuer's JWKS
// → { sub: "<pairwise>", given_name: "…", family_name: "…", … }Try it now against the sandbox: the sandbox page lists its issuer and the public client sandbox-demo, with http://localhost:3000/callback as redirect URI.
React SDK
Version 0.7.0 or later: npm install @proofingtrust/react@^0.7.0 (earlier versions predate this API). For ProofingTrust customers, under a proprietary license: it only works with a client ID we issue to you. The policy must allow the flow you use. Set the client ID's allowedOrigins to your site's origins: browsers on other sites are refused (it is not authentication: outside a browser anyone can send any Origin).
QR (cross-device)
import { ProofingTrustLoginButton } from '@proofingtrust/react'
<ProofingTrustLoginButton
clientId="ptc_live_xxxxxx"
onLogin={async result => {
// QR (cross-device): the verdict and result.sessionId, never the claims.
// Your backend reads the claims once, with its API key (see "REST API").
await fetch('/my-backend/login', { method: 'POST', body: JSON.stringify({ sessionId: result.sessionId }) })
}}
onError={reason => console.error(reason)}
/>Same device
// Same device: opens the wallet on this phone. The wallet comes back to your policy's
// return address with #response_code=… (a fragment: it never reaches your server's logs);
// the button on that page redeems it, once, and gets the claims in result.claims.
<ProofingTrustLoginButton clientId="ptc_live_xxxxxx" sameDevice onLogin={...} onError={...} />Digital Credentials API
import { requestWithDigitalCredentials } from '@proofingtrust/react'
// The browser asks the user's wallet itself (OpenID4VP 1.0 Appendix A): no QR, no deep link.
const outcome = await requestWithDigitalCredentials({ clientId: 'ptc_live_xxxxxx' })
// { phase: 'valid', result } | { phase: 'invalid', reason } | { phase: 'pending' }In a browser that supports the Digital Credentials API; the policy needs dc-api among its flows and your site among its expectedOrigins. The claims come only in the answer that closed the session as valid.
REST API with an API key
For your backend. reference is your own id (an order, a case; up to 128 printable characters, never personal data) and comes back in the result and in webhooks.
# 1. Open a session from your server (the API key never goes to a browser)
curl -X POST https://api.proofingtrust.xyz/v1/public/sessions \
-H "X-API-Key: pt_live_…" -H "Content-Type: application/json" \
-d '{"reference":"order-123"}'
# → 201 { "sessionId": "…", "requestUri": "…", "deepLink": "openid4vp://…", "expiresAt": "…" }
# Show deepLink as a QR. For the phone that holds the wallet: "flow": "same-device".
# 2. Read the result. The first finished read carries the claims, once
curl -H "X-API-Key: pt_live_…" https://api.proofingtrust.xyz/v1/public/sessions/<sessionId>
# → { "status": "completed", "result": "valid", "reference": "order-123", "claimsStatus": "delivered",
# "credentials": [{ "queryId": "…", "accepted": true, "claims": { … } }] }
# A second read: "claims": null. Unread claims expire after 5 minutes ("claimsStatus": "expired").Same device: redeem the response_code
# Same device: your return page takes response_code from the URL fragment and hands it
# to your backend, which redeems it (single use, 5 minutes)
curl -X POST https://api.proofingtrust.xyz/v1/public/sessions/<sessionId>/redeem \
-H "X-API-Key: pt_live_…" -H "Content-Type: application/json" \
-d '{"response_code":"<code>"}'
# → 200 { "status": "completed", "result": "valid", "credentials": [{ …, "claims": { … } }] }
# The same code again: 400 invalid_response_codeOnly whoever holds the response_code gets the result, and the redeem also needs the sessionId, which the wallet never sees. The public /status endpoint gives the verdict to a browser and never carries claims.
Webhooks
Register up to 10 https destinations in the client area (Webhooks tab), for all your policies or some. Events: verification.completed, verification.failed and session.expired, signed as Standard Webhooks: headers webhook-id, webhook-timestamp and webhook-signature, body { type, timestamp, data }.
import { createServer } from "node:http";
import { Webhook } from "standardwebhooks"; // 1.1.1, the reference library our test receiver uses
const wh = new Webhook(process.env.PROOFINGTRUST_WEBHOOK_SECRET); // "whsec_…", shown once
const processed = new Set(); // your database, in production
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString("utf8"); // the exact bytes: never re-serialise
let event;
try {
event = wh.verify(body, req.headers); // webhook-id, webhook-timestamp (±5 min), webhook-signature
} catch {
res.writeHead(400).end();
return;
}
if (event.type === "webhook.endpoint_verification") {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ challenge: event.data.challenge }));
return;
}
const id = req.headers["webhook-id"]; // the same on every retry: your idempotency key
if (!processed.has(id)) {
processed.add(id);
// event.type: verification.completed | verification.failed | session.expired
// event.data: sessionId, policyId, reference, flow, result, failureCode, credentials… never claims
}
res.writeHead(204).end();
}).listen(3000);- Ownership first. A destination only receives events after it echoes a signed challenge. When you create it you do not have the secret yet, so the check fails: store the secret, then press Verify.
- Retries follow the Standard Webhooks schedule, up to 10 attempts over a day, with the same
webhook-id. A410disables the destination; redirects are not followed. - No claims by default. Only with sealed storage (below) and
includeClaimsdoesverification.completedcarry them.
What we keep, and what we don't
- Nothing presented, by default. The attributes of a valid verification are handed once to an authenticated channel (your API key, the same-device redeem, the Digital Credentials API answer, the ID Token) and erased. Unread, they expire after 5 minutes.
- If you need to read them more than once, turn on sealed storage for your tenant or a policy: encrypted, from 5 minutes to 24 hours, and you can erase them earlier.
- The trace: verdict, policy, flow and failure codes, with no attribute, holder or credential. 90 days by default, from 1 to 730.
- The OpenID Connect login keeps the attributes sealed only between the presentation and your token request (11 minutes at most).
- Logs carry no attributes, tokens or codes.
What is tested, and what isn't
- Our verifier passes the OpenID conformance suite for OID4VP 1.0 under HAIP: public conformance plan. "Conformance passed": we have not applied for the formal OpenID certification.
- Every flow on this page runs in our end-to-end test against the full platform on every change: the OpenID Connect login with the
openid-clientRP library, the API, same device, the Digital Credentials API and webhooks checked with the Standard Webhooks library. - Not yet tested: the OpenID Connect login and the sandbox with a real wallet, a third-party relying party, a third-party webhook receiver. Tell us if you are the first.
Ready to issue your first
Verifiable Credential?
Whether you're exploring the EUDI ecosystem, need to issue credentials at scale, or want to integrate verification into an existing product — we'd love to talk.