Developers

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 ID ptc_live_… is public, for the browser, limited to the origins you allow (allowedOrigins). Sandbox keys start with pt_test_ / ptc_test_ and only work there.
  • Want to try first? The sandbox needs none of this.
You wantUse
"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 APIReact SDK
Full control from your backendREST API
To be told when a verification endsWebhooks

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 carries iss (RFC 9207). Client authentication: client_secret_basic (default), client_secret_post, private_key_jwt or none; ID Token in RS256 (default) or ES256.
  • A pairwise sub by 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 same sub in your app and a different one elsewhere. Without a subject attribute in the policy, the sub is 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_code

Only 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. A 410 disables the destination; redirects are not followed.
  • No claims by default. Only with sealed storage (below) and includeClaims does verification.completed carry 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-client RP 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.
Contact

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.

EUDI Wallet Compatible
Quick onboarding
Open standards, conformance-tested

Get in touch

Or email contact@proofingtrust.xyz