Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What is PylonID?

PylonID is a turnkey API for verifying attributes from European Digital Identity (EUDI) wallets. Instead of implementing 500+ pages of cryptographic standards, you call one REST endpoint.

One Endpoint. One QR Code. One Webhook.

POST /v1/verify/age
{
  "policy": { "minAge": 18 },
  "callbackUrl": "https://yourapp.com/webhooks/pylon"
}

Returns a walletUrl — display it as a QR code. Customer scans with their EUDI wallet, consents, and you receive a signed webhook with the result. That’s it.

How It Works

Your app                          PylonID                         EUDI Wallet
   │                                 │                                 │
   │  POST /v1/verify/age            │                                 │
   │────────────────────────────────>│                                 │
   │  { walletUrl, verificationId }  │                                 │
   │<────────────────────────────────│                                 │
   │                                 │                                 │
   │  Show QR code (walletUrl)       │                                 │
   │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ >│
   │                                 │   Wallet fetches request JWT    │
   │                                 │<────────────────────────────────│
   │                                 │   Signed authorization request  │
   │                                 │────────────────────────────────>│
   │                                 │                                 │
   │                                 │   User consents                 │
   │                                 │                                 │
   │                                 │   Wallet sends VP token         │
   │                                 │<────────────────────────────────│
   │                                 │                                 │
   │  Webhook: verified/rejected     │                                 │
   │<────────────────────────────────│                                 │

Current Status

🟢 Beta — OpenID4VP age verification with real EUDI wallet support.

Working Now

  • ✅ Age verification via OpenID4VP
  • ✅ SD-JWT-VC parsing and ES256 signature verification
  • ✅ Signed authorization request objects
  • ✅ Key Binding JWT verification
  • ✅ JWKS fetching from PID Issuer
  • ✅ HMAC-SHA256 signed webhooks
  • ✅ API key management (signup, rotation)
  • ✅ Integrated PID Issuer (Keycloak + EUDI reference issuer)

Planned

  • 🔄 KYC attribute verification (Q3 2026)
  • 🔄 OAuth/OIDC “Sign in with EUDI” (Q4 2026)
  • 🔄 Official SDKs — Go, JS, Python, Rust, Java (Q4 2026)

Why PylonID?

Time to integrate10 minutes
Learning curveREST API, not cryptography
Data sovereigntyEU-only, self-hosted, no US sub-processors
Lock-inNone — standards-native (OpenID4VP, SD-JWT-VC)
DeploymentSelf-hosted via Docker Compose

eIDAS 2.0 Compliance

The European Digital Identity Regulation mandates:

  • Dec 2026: Member states must provide EUDI wallets to citizens
  • Dec 2027: Financial, healthcare, and mobility sectors must accept EUDI wallets

PylonID is built for this deadline.

Next Steps


Questions? See Troubleshooting or email hello@pylonid.eu

Quickstart: Verify Age in 10 Minutes

Verify customer ages using EUDI wallets. One API call, one QR code, one webhook.

Prerequisites

  • A running PylonID instance (self-hosted or pylonid.eu)
  • An API key (see Step 1)
  • A webhook endpoint that accepts HTTPS POST requests

Step 1: Get an API Key

curl -X POST https://pylonid.eu/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

Save the returned API key. You’ll need it for all authenticated requests.


Step 2: Start an Age Verification

curl -X POST https://pylonid.eu/v1/verify/age \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "policy": { "minAge": 18 },
    "callbackUrl": "https://yourapp.com/webhooks/pylon"
  }'

Response:

{
  "verificationId": "ver_81CA6EC3CA80",
  "status": "pending",
  "walletUrl": "eudi-openid4vp://authorize?request_uri=https%3A%2F%2Fpylonid.eu%2Fv1%2Foid4vp%2Frequest%2Fver_81CA6EC3CA80",
  "requestUri": "https://pylonid.eu/v1/oid4vp/request/ver_81CA6EC3CA80",
  "expiresAt": "2026-04-22T00:15:00Z"
}

Step 3: Show the QR Code

Display walletUrl as a QR code. On mobile, it opens the EUDI wallet directly via the eudi-openid4vp:// deep link.

Your app                          PylonID                         EUDI Wallet
   │                                 │                                 │
   │  Show QR (walletUrl)            │                                 │
   │─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│
   │                                 │   GET /v1/oid4vp/request/:id    │
   │                                 │<────────────────────────────────│
   │                                 │   Signed request JWT            │
   │                                 │────────────────────────────────>│
   │                                 │                                 │
   │                                 │   User consents                 │
   │                                 │                                 │
   │                                 │  POST /v1/oid4vp/response       │
   │                                 │<────────────────────────────────│
   │                                 │  (SD-JWT-VC with age_over_18)   │
   │                                 │                                 │
   │  Webhook: verified/rejected     │                                 │
   │<────────────────────────────────│                                 │

Step 4: Receive the Webhook

When the user completes verification, PylonID POSTs to your callbackUrl:

{
  "event": "verification.completed",
  "verificationId": "ver_81CA6EC3CA80",
  "status": "verified",
  "result": { "age_over_18": true },
  "timestamp": "2026-04-22T00:05:00Z"
}

Step 5: Verify the Webhook Signature

PylonID signs every webhook with HMAC-SHA256. Always validate before trusting the payload.

The signature is in the X-Pylon-Signature header:

X-Pylon-Signature: sha256=a1b2c3d4e5f6...

Node.js

const crypto = require('crypto');

app.post('/webhooks/pylon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-pylon-signature'];
  const body = req.body; // raw Buffer

  const computed = 'sha256=' + crypto
    .createHmac('sha256', process.env.PYLON_WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(body);
  console.log(`Verified: ${payload.verificationId} → ${payload.status}`);
  res.status(200).json({ received: true });
});

Python

import hmac, hashlib, os
from flask import Flask, request

@app.route('/webhooks/pylon', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Pylon-Signature', '')
    body = request.get_data()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, computed):
        return {'error': 'Invalid signature'}, 401

    data = request.json
    print(f"Verified: {data['verificationId']} → {data['status']}")
    return {'received': True}, 200

Go

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Pylon-Signature")
    body, _ := io.ReadAll(r.Body)

    h := hmac.New(sha256.New, []byte(os.Getenv("PYLON_WEBHOOK_SECRET")))
    h.Write(body)
    computed := "sha256=" + hex.EncodeToString(h.Sum(nil))

    if !hmac.Equal([]byte(signature), []byte(computed)) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received":true}`))
}

Step 6: Poll Status (Optional)

Instead of (or in addition to) webhooks, you can poll:

curl https://pylonid.eu/v1/status/ver_81CA6EC3CA80 \
  -H "Authorization: Bearer YOUR_API_KEY"

Error Cases

StatusMeaningFix
401Invalid or missing API keyCheck Authorization: Bearer header
400Invalid request bodyCheck JSON syntax, minAge range, callbackUrl is HTTPS
429Rate limitedBack off and retry
500Server errorRetry; contact hello@pylonid.eu if persistent

Test Locally

Use the Rust-based emulator for offline development:

cd pylon_cli
cargo run --release

Then point your requests at http://localhost:7777 instead of https://pylonid.eu.


Next Steps

Core Concepts

PylonID abstracts three standards: OpenID4VP, SD-JWT-VC, and ES256 signatures. You don’t need to know them to use PylonID, but understanding the fundamentals helps.


Verifiable Credentials (VCs)

A Verifiable Credential is a digitally signed claim about a person, issued by a trusted authority.

Example: A government issues a credential: “Anna Müller, born 1990, age over 18: true”

Properties:

  • Issued by a trusted entity (e.g., Austrian government)
  • Cryptographically signed (can’t be forged)
  • Has an expiry (e.g., 5 years from issuance)
  • Supports selective disclosure (reveal only “age over 18”, not full birthdate)

SD-JWT-VC: Selective Disclosure

SD-JWT-VC = Selective Disclosure JSON Web Token Verifiable Credential

A JWT where individual claims can be selectively revealed by the holder.

How it works:

  1. Issuer creates a JWT with hashed claim placeholders (_sd array)
  2. Each real claim value is in a separate disclosure (base64-encoded)
  3. Holder chooses which disclosures to reveal
  4. Verifier receives the JWT + selected disclosures, reconstructs only the revealed claims

In PylonID: When a wallet responds with a VP token, PylonID parses the SD-JWT-VC, matches disclosure hashes against _sd arrays, verifies the issuer signature, and extracts the revealed claims (e.g., age_over_18).


OpenID4VP: Presentation Protocol

OpenID4VP = OpenID for Verifiable Presentations

The protocol for requesting and receiving verifiable credentials from wallets.

The flow:

1. PylonID creates a signed authorization request JWT containing:
   - presentation_definition: "I need age_over_18 from a PID credential"
   - response_uri: where the wallet should send the response
   - nonce: replay protection

2. Wallet fetches this request via request_uri

3. User sees: "PylonID wants to verify your age. Share age_over_18?"

4. User consents → wallet creates VP token:
   - SD-JWT-VC with only age_over_18 disclosed
   - Key Binding JWT (proves wallet holds the private key)

5. Wallet POSTs VP token to PylonID's response_uri

6. PylonID validates everything and fires webhook

In PylonID: You call POST /v1/verify/age. PylonID handles the entire OID4VP handshake — request signing, nonce management, token validation, issuer verification.


ES256 Signatures

ES256 = ECDSA using P-256 curve and SHA-256

Used throughout the EUDI ecosystem:

  • PID Issuer signs credentials with ES256
  • PylonID signs authorization requests with ES256
  • Wallet signs Key Binding JWTs with ES256

PylonID verifies issuer signatures by fetching the issuer’s public keys via JWKS (JSON Web Key Set).


Key Binding JWT

When a wallet presents a credential, it includes a Key Binding JWT — a short-lived token proving the wallet actually holds the private key associated with the credential.

PylonID verifies:

  • Nonce matches the authorization request
  • Audience matches PylonID’s client_id
  • Freshness — issued within the last 5 minutes
  • Signature — valid ES256 against the cnf.jwk in the credential

The Verification Flow (Complete)

POST /v1/verify/age
  → PylonID creates verification record with nonce
  → Returns eudi-openid4vp:// wallet URL

Wallet scans QR code
  → GET /v1/oid4vp/request/:id
  → Receives signed ES256 JWT with presentation_definition

User consents in wallet
  → POST /v1/oid4vp/response
  → Sends vp_token (SD-JWT-VC) + state

PylonID validates:
  1. Parse SD-JWT → issuer JWT + disclosures + key binding JWT
  2. Check issuer URL and VCT (urn:eudi:pid:1)
  3. Fetch issuer JWKS → verify ES256 signature on issuer JWT
  4. Verify Key Binding JWT (nonce, audience, freshness, signature)
  5. Match disclosure hashes against _sd arrays
  6. Reconstruct revealed claims → extract age_over_18
  7. Update verification status → fire webhook

Trust Model

PylonID trusts credentials based on:

  1. Issuer identity — credential must come from a configured trusted PID Issuer
  2. Cryptographic signature — issuer’s ES256 signature must verify against their published JWKS
  3. Credential type — must be urn:eudi:pid:1 (EU Person Identification Data)
  4. Freshness — Key Binding JWT must be recent (5-minute window)
  5. Binding — wallet must prove possession of the credential’s private key

Wallet Ecosystems

Government EUDI Wallets

Issued by EU member states under eIDAS 2.0. Austria, Germany, Italy, and others are deploying wallets.

Commercial EUDI Wallets

Third-party wallets (Lissi, Verimi, walt.id) implementing the same standards.

Mobile OS Integration

Native EUDI wallet support planned for iOS and Android.

All compliant wallets speak the same protocol — PylonID works with any of them.

See Wallet Interoperability for details.


Next Steps


Questions? See Troubleshooting or email hello@pylonid.eu

API Reference

Base URL: https://pylonid.eu

All authenticated endpoints require:

Authorization: Bearer YOUR_API_KEY

Authentication

POST /v1/auth/signup

Create a new API key.

Request:

curl -X POST https://pylonid.eu/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

Response (200):

{
  "apiKey": "pyl_...",
  "email": "you@example.com"
}

Store this key securely — it cannot be retrieved again.


POST /v1/auth/rotate

Rotate your API key. Requires current key for authentication.

Request:

curl -X POST https://pylonid.eu/v1/auth/rotate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Response (200):

{
  "apiKey": "pyl_...",
  "previous": "revoked"
}

The old key is immediately invalidated.


Age Verification

POST /v1/verify/age

Start an age verification request. Returns a wallet URL for the user to scan.

Request:

curl -X POST https://pylonid.eu/v1/verify/age \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "policy": { "minAge": 18 },
    "callbackUrl": "https://yourapp.com/webhooks/pylon"
  }'

Request Body:

FieldTypeRequiredDescription
policy.minAgeintegerYesMinimum age (1–150)
callbackUrlstringYesHTTPS URL for webhook delivery

Response (200):

{
  "verificationId": "ver_81CA6EC3CA80",
  "status": "pending",
  "walletUrl": "eudi-openid4vp://authorize?request_uri=https%3A%2F%2Fpylonid.eu%2Fv1%2Foid4vp%2Frequest%2Fver_81CA6EC3CA80",
  "requestUri": "https://pylonid.eu/v1/oid4vp/request/ver_81CA6EC3CA80",
  "expiresAt": "2026-04-22T00:15:00Z"
}
FieldDescription
verificationIdUnique ID for this verification
walletUrlDeep link for EUDI wallet — display as QR code
requestUriHTTPS URL the wallet fetches the signed request from
expiresAtVerification expires after this time

GET /v1/status/:id

Check the status of a verification.

Request:

curl https://pylonid.eu/v1/status/ver_81CA6EC3CA80 \
  -H "Authorization: Bearer YOUR_API_KEY"

Response (200):

{
  "verificationId": "ver_81CA6EC3CA80",
  "status": "pending",
  "result": null,
  "createdAt": "2026-04-22T00:00:00Z",
  "expiresAt": "2026-04-22T00:15:00Z"
}

Status values: pending, verified, rejected, expired


Wallet Endpoints

These are called by the EUDI wallet, not by your application. No authentication required.

GET /v1/oid4vp/request/:id

The wallet fetches the signed authorization request JWT from this endpoint.

Response: Signed ES256 JWT containing:

  • presentation_definition requesting age_over_18 from urn:eudi:pid:1
  • response_uri — where the wallet sends the VP token
  • nonce and state for replay protection
  • client_id — PylonID verifier identifier

POST /v1/oid4vp/response

The wallet submits the Verifiable Presentation token here.

Request body: application/x-www-form-urlencoded

FieldDescription
vp_tokenSD-JWT-VC with selective disclosures and key binding JWT
stateMatches the state from the authorization request
presentation_submissionJSON describing which credential satisfies which input descriptor

Processing:

  1. Parse SD-JWT-VC (issuer JWT + disclosures + key binding JWT)
  2. Fetch issuer JWKS and verify ES256 signature
  3. Verify key binding JWT (nonce, audience, freshness)
  4. Reconstruct claims from selective disclosures
  5. Extract age_over_18
  6. Update verification status
  7. Fire webhook to SMB callback URL

Discovery

GET /.well-known/openid-credential-verifier

Verifier metadata and public key for wallet trust establishment.

Response (200):

{
  "issuer": "https://pylonid.eu",
  "jwks": {
    "keys": [
      {
        "kty": "EC",
        "crv": "P-256",
        "kid": "pylonid-verifier-1",
        "use": "sig",
        "x": "...",
        "y": "..."
      }
    ]
  }
}

GET /health

Service health check.

Response (200):

{
  "status": "ok",
  "service": "pylon-server",
  "version": "1.0.0"
}

Webhook Delivery

When a verification completes, PylonID POSTs to your callbackUrl.

Headers:

Content-Type: application/json
X-Pylon-Signature: sha256=a1b2c3d4...

Body:

{
  "event": "verification.completed",
  "verificationId": "ver_81CA6EC3CA80",
  "status": "verified",
  "result": { "age_over_18": true },
  "timestamp": "2026-04-22T00:05:00Z"
}

Signature verification:

computed = "sha256=" + HMAC-SHA256(webhook_secret, raw_body).hex()
assert computed == headers["X-Pylon-Signature"]

See Webhooks for retry policy, idempotency, and implementation examples.


Error Responses

401 Unauthorized:

{ "error": "invalid_api_key", "message": "API key not found or expired" }

400 Bad Request:

{ "error": "invalid_request", "message": "callbackUrl must be HTTPS" }

429 Too Many Requests:

{ "error": "rate_limited", "message": "Too many requests" }

500 Internal Server Error:

{ "error": "internal_error", "message": "Unexpected server error" }

Rate Limits

  • Request rate limiting is enforced per API key
  • Webhook timeout: 30 seconds
  • Webhook retries: exponential backoff

Questions? See Troubleshooting or email hello@pylonid.eu

Integration & Testing

How to set up your environment, test your integration, and go to production.


Setup

1. Get an API Key

curl -X POST https://pylonid.eu/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

Store the returned API key securely. It cannot be retrieved again.

2. Configure Your Environment

# Your app needs these two values
export PYLON_API_KEY="pyl_..."           # From signup
export PYLON_WEBHOOK_SECRET="..."         # Provided with your API key

Store in your secrets manager (AWS Secrets Manager, Vault, etc.) — never in source code.

3. Set Up a Webhook Endpoint

Your app needs an HTTPS endpoint that:

  • Accepts POST requests
  • Returns HTTP 200 within 30 seconds
  • Validates the X-Pylon-Signature header

See Webhooks for implementation examples in Node.js, Python, Go, Java, and Rust.


Testing Progression

1. Local emulator (no network, no wallet needed)
   ↓
2. Live API with test verifications
   ↓
3. Real EUDI wallet end-to-end test
   ↓
4. Production (real users)

Stage 1: Local Emulator

The Rust-based emulator runs the full API locally with a mock wallet:

cd pylon_cli
cargo run --release
# Emulator runs on http://localhost:7777

Point your app at http://localhost:7777 instead of https://pylonid.eu. The emulator auto-completes verifications and fires webhooks immediately.

See Local Emulator for details.

Stage 2: Live API

Switch your app to https://pylonid.eu with your real API key. Create verifications and confirm:

  • You receive the walletUrl and verificationId
  • Your webhook endpoint is reachable
  • Signature validation works
  • Status polling works via GET /v1/status/:id

Stage 3: Real Wallet Test

Scan the QR code with an actual EUDI wallet app. Verify the full flow end-to-end:

  • Wallet opens and shows consent screen
  • User accepts → webhook fires with verified
  • User denies → webhook fires with rejected

Stage 4: Production

Once all stages pass, you’re ready for real users.


Pre-Production Checklist

Security

  • API key stored in secrets manager (not in code or git)
  • Webhook endpoint uses HTTPS with valid TLS certificate
  • Webhook signature validation implemented and tested
  • API key rotation process documented

Reliability

  • Webhook handler returns 200 immediately, processes async
  • Status polling implemented as webhook backup
  • Error handling for all API response codes (400, 401, 429, 500)
  • Retry logic for transient network failures

Monitoring

  • Webhook delivery success rate tracked
  • API error rate tracked
  • Alerts configured for failures

Compliance

  • Privacy policy updated to mention age verification via EUDI wallet
  • Data retention policy defined for verification results
  • GDPR data subject request process covers verification data

API Key Management

# Create a new key
curl -X POST https://pylonid.eu/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com"}'

# Rotate (invalidates old key immediately)
curl -X POST https://pylonid.eu/v1/auth/rotate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Best practices:

  • Rotate keys periodically
  • Revoke immediately if compromised
  • Use separate keys per environment if running multiple instances
  • Never commit keys to source control

Webhook Testing with ngrok

To test webhooks during development without deploying:

ngrok http 3000
# Returns: https://abc123.ngrok.io → localhost:3000

Use the ngrok HTTPS URL as your callbackUrl:

curl -X POST https://pylonid.eu/v1/verify/age \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "policy": { "minAge": 18 },
    "callbackUrl": "https://abc123.ngrok.io/webhooks/pylon"
  }'

Self-Hosting

PylonID can also be self-hosted on your own infrastructure. See the repository README for Docker Compose deployment instructions.


Questions? See Troubleshooting or email hello@pylonid.eu

Local Testing with Emulator

Test the full age verification flow offline, without a real EUDI wallet.


Quick Start

cd pylon_cli
cargo build --release
./target/release/pylon-cli

The emulator starts on http://localhost:7777 and provides the same API as the production server.


Test Workflow

Step 1: Create a Verification

curl -X POST http://localhost:7777/v1/verify/age \
  -H "Content-Type: application/json" \
  -d '{
    "policy": { "minAge": 18 },
    "callbackUrl": "http://localhost:3000/webhook"
  }'

Response:

{
  "verificationId": "ver_local_ABC123",
  "status": "pending",
  "walletUrl": "http://localhost:7777/scan/ver_local_ABC123"
}

Step 2: Simulate Wallet Response

Open http://localhost:7777/scan/ver_local_ABC123 in your browser. You’ll see a simple UI with Accept and Reject buttons.

  • Accept → emulator fires webhook with "status": "verified"
  • Reject → emulator fires webhook with "status": "rejected"

Step 3: Receive Webhook

Your webhook endpoint at http://localhost:3000/webhook receives:

{
  "event": "verification.completed",
  "verificationId": "ver_local_ABC123",
  "status": "verified",
  "result": { "age_over_18": true },
  "timestamp": "2026-04-22T00:05:00Z"
}

Integration Example (Express.js)

const express = require('express');
const app = express();
app.use(express.json());

// Start verification
app.get('/start', async (req, res) => {
  const resp = await fetch('http://localhost:7777/v1/verify/age', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      policy: { minAge: 18 },
      callbackUrl: 'http://localhost:3000/webhook'
    })
  });
  const data = await resp.json();
  res.json(data); // Return walletUrl to display as QR
});

// Receive webhook
app.post('/webhook', (req, res) => {
  console.log(`${req.body.verificationId}: ${req.body.status}`);
  res.status(200).json({ received: true });
});

app.listen(3000, () => console.log('App on :3000'));

Test it:

# Terminal 1: Start your app
node app.js

# Terminal 2: Start emulator
cd pylon_cli && cargo run --release

# Terminal 3: Trigger verification
curl http://localhost:3000/start
# Open the returned walletUrl in browser, click Accept

Emulator vs Production

AspectEmulatorProduction
URLhttp://localhost:7777https://pylonid.eu
WalletBrowser UI (Accept/Reject)Real EUDI wallet app
SignaturesNo SD-JWT-VC validationFull ES256 + JWKS verification
WebhooksFires immediately, no retriesRetries with exponential backoff
StorageIn-memory (clears on restart)PostgreSQL
AuthNo API key requiredAPI key required
Webhook signingNo signatureHMAC-SHA256 signed

Prerequisites

# Rust 1.75+
rustc --version

# Build
cd pylon_cli
cargo build --release

Troubleshooting

Port 7777 already in use

lsof -i :7777
kill -9 <PID>

Webhook not firing

Test that your endpoint is reachable:

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

Connection refused

Emulator not running. Start it with cargo run --release from the pylon_cli directory.


Next Steps

Once your integration works locally:

  1. Switch to https://pylonid.eu with a real API key
  2. Add webhook signature validation (emulator doesn’t sign, production does)
  3. Test with a real EUDI wallet

See Integration & Testing for the full testing progression.


Questions? See Troubleshooting or email hello@pylonid.eu

Webhooks: Production Guide

PylonID delivers verification results asynchronously via signed webhooks.


Overview

After a user completes verification in their EUDI wallet, PylonID sends a signed HTTP POST to your callbackUrl.

1. User consents in EUDI wallet
2. Wallet sends VP token to PylonID
3. PylonID validates SD-JWT-VC cryptographic proof
4. PylonID POSTs result to your callbackUrl
5. Your app returns HTTP 200
6. Webhook marked as delivered

Webhook Format

Headers:

Content-Type: application/json
X-Pylon-Signature: sha256=a1b2c3d4e5f6...

Body:

{
  "event": "verification.completed",
  "verificationId": "ver_81CA6EC3CA80",
  "status": "verified",
  "result": { "age_over_18": true },
  "timestamp": "2026-04-22T00:05:00Z"
}

Possible Status Values

StatusMeaning
verifiedUser proved age ≥ minimum
rejectedUser denied consent or credential didn’t meet policy
expiredUser didn’t respond before timeout
errorTechnical failure

Signature Validation (CRITICAL)

Always validate webhook signatures. Without validation, anyone can forge a webhook to your endpoint.

PylonID signs the raw request body with HMAC-SHA256:

X-Pylon-Signature: sha256={hex(HMAC-SHA256(secret, raw_body))}

Validation Steps

  1. Get raw request body (bytes, before JSON parsing)
  2. Compute HMAC-SHA256(your_webhook_secret, raw_body)
  3. Format as sha256={hex}
  4. Compare to X-Pylon-Signature header using constant-time comparison

Node.js

const crypto = require('crypto');

function validateSignature(body, signature, secret) {
  const computed = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed));
}

app.post('/webhooks/pylon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-pylon-signature'];

  if (!validateSignature(req.body, signature, process.env.PYLON_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body);
  console.log(`${payload.verificationId}: ${payload.status}`);
  res.status(200).json({ received: true });
});

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request

app = Flask(__name__)

def validate_signature(body, signature, secret):
    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, computed)

@app.route('/webhooks/pylon', methods=['POST'])
def pylon_webhook():
    signature = request.headers.get('X-Pylon-Signature', '')
    body = request.get_data()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    if not validate_signature(body, signature, secret):
        return {'error': 'Invalid signature'}, 401

    data = request.json
    print(f"{data['verificationId']}: {data['status']}")
    return {'received': True}, 200

Python (FastAPI)

import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def validate_signature(body: bytes, signature: str, secret: str) -> bool:
    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, computed)

@app.post('/webhooks/pylon')
async def pylon_webhook(request: Request):
    signature = request.headers.get('X-Pylon-Signature', '')
    body = await request.body()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    if not validate_signature(body, signature, secret):
        raise HTTPException(status_code=401, detail='Invalid signature')

    data = await request.json()
    print(f"{data['verificationId']}: {data['status']}")
    return {'received': True}

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func validateSignature(body []byte, signature, secret string) bool {
    h := hmac.New(sha256.New, []byte(secret))
    h.Write(body)
    computed := "sha256=" + hex.EncodeToString(h.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(computed))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Pylon-Signature")
    body, _ := io.ReadAll(r.Body)

    if !validateSignature(body, signature, os.Getenv("PYLON_WEBHOOK_SECRET")) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received":true}`))
}

Java (Spring Boot)

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

@PostMapping("/webhooks/pylon")
public ResponseEntity<?> handleWebhook(
    @RequestHeader("X-Pylon-Signature") String signature,
    @RequestBody String body
) {
    String secret = System.getenv("PYLON_WEBHOOK_SECRET");

    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] hash = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder("sha256=");
        for (byte b : hash) sb.append(String.format("%02x", b));
        String computed = sb.toString();

        if (!computed.equals(signature)) {
            return ResponseEntity.status(401).build();
        }
    } catch (Exception e) {
        return ResponseEntity.status(500).build();
    }

    return ResponseEntity.ok(Map.of("received", true));
}

Rust (Axum)

#![allow(unused)]
fn main() {
use axum::{http::{HeaderMap, StatusCode}, Json};
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

fn validate_signature(body: &[u8], signature: &str, secret: &str) -> bool {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(body);
    let computed = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
    subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), signature.as_bytes()).into()
}

async fn webhook_handler(headers: HeaderMap, body: String) -> Result<Json<serde_json::Value>, StatusCode> {
    let signature = headers.get("x-pylon-signature")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;

    let secret = std::env::var("PYLON_WEBHOOK_SECRET")
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !validate_signature(body.as_bytes(), signature, &secret) {
        return Err(StatusCode::UNAUTHORIZED);
    }

    Ok(Json(serde_json::json!({"received": true})))
}
}

Retry Policy

If your endpoint doesn’t return HTTP 2xx within 30 seconds, PylonID retries with exponential backoff:

AttemptDelay
1Immediate
230 seconds
35 minutes
41 hour
524 hours

After 5 failed attempts, the webhook is marked as failed.

Best practice: Return 200 immediately, process asynchronously:

app.post('/webhooks/pylon', (req, res) => {
  // Validate signature...
  res.status(200).json({ received: true }); // Return immediately
  queue.add('processVerification', JSON.parse(req.body)); // Background
});

Endpoint Requirements

  • HTTPS only (HTTP rejected)
  • Publicly accessible (not localhost)
  • Returns HTTP 2xx within 30 seconds
  • Valid TLS certificate (Let’s Encrypt works)

Testing Locally

Use ngrok or similar to expose a local endpoint:

ngrok http 3000
# Use the HTTPS URL as your callbackUrl

Common Issues

Signature validation always fails

// ❌ WRONG: Using parsed JSON body
const body = JSON.stringify(req.body);

// ✅ RIGHT: Using raw body bytes
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body; // Buffer, not parsed object
});

Always use the raw request body for signature validation. JSON parsing and re-serializing can change whitespace, key order, or encoding.

Webhook never fires

  1. Is callbackUrl HTTPS? (HTTP is rejected)
  2. Is it publicly reachable? Test with curl from another machine
  3. Does it return HTTP 2xx?
  4. Check verification status via GET /v1/status/:id — is it still pending?

Webhook times out

Return HTTP 200 immediately and process in the background. Don’t do database writes, API calls, or heavy computation before responding.


Questions? See Troubleshooting or email hello@pylonid.eu

Security & Compliance

PylonID is designed and developed independently by a sole developer. This document describes implemented security measures, compliance posture, and your responsibilities.


Data Sovereignty

  • All data processing occurs within the EU (self-hosted on EU infrastructure)
  • No external sub-processors or third-party data processors
  • No data leaves the deployment — PylonID doesn’t phone home

Encryption

At Rest

  • AES-256-GCM encryption for sensitive data in PostgreSQL
  • Master encryption key provided via environment variable
  • API keys hashed with bcrypt before storage

In Transit

  • TLS 1.3 minimum for all external connections
  • EUDI wallet communication over HTTPS
  • Webhook delivery over HTTPS only

Cryptographic Standards

  • ES256 (ECDSA P-256 + SHA-256) for authorization request signing
  • ES256 for SD-JWT-VC issuer signature verification
  • HMAC-SHA256 for webhook payload signing
  • JWKS for public key distribution and rotation

Authentication & Authorization

  • API key authentication for all SMB endpoints
  • Keys generated with cryptographic randomness
  • Keys hashed with bcrypt — PylonID cannot retrieve your plaintext key
  • Key rotation via POST /v1/auth/rotate (immediate invalidation of old key)

Webhook Security

  • Every webhook signed with HMAC-SHA256
  • Signature in X-Pylon-Signature: sha256={hex} header
  • Constant-time signature comparison (timing-attack resistant)
  • HTTPS-only delivery (HTTP callback URLs rejected)

Credential Verification

PylonID validates every presented credential:

  1. Issuer trust — credential must come from a configured PID Issuer
  2. Signature — ES256 signature verified against issuer’s JWKS
  3. Credential type — must be urn:eudi:pid:1
  4. Key binding — wallet proves possession of credential private key
  5. Freshness — Key Binding JWT must be within 5-minute window
  6. Nonce — must match the original authorization request

Data Minimization

  • Only requested attributes are disclosed (selective disclosure via SD-JWT-VC)
  • Age verification reveals only age_over_18 (boolean), not birthdate
  • Verification records store the result, not the raw credential
  • Automatic cleanup of expired records via background worker

Compliance Status

StandardStatus
eIDAS 2.0Architecturally compliant (OpenID4VP, SD-JWT-VC)
GDPRDesigned for compliance (data minimization, EU-only)
ISO 27001Not certified (planned)
SOC 2Not audited (planned)

PylonID is a beta product developed by a solo developer. No formal certifications or third-party audits have been obtained yet. The system is architected to follow EU standards and best practices.


Audit Logging

  • Immutable audit trail for verification events
  • Anonymized IP logging
  • Logs retained for 1 year minimum

Your Responsibilities

  • Store API keys securely (secrets manager, not source code)
  • Rotate API keys periodically
  • Validate webhook signatures on every request
  • Use HTTPS-only webhook endpoints
  • Implement idempotent webhook processing
  • Define data retention policies for verification results
  • Update your privacy policy to mention EUDI wallet verification
  • Handle GDPR data subject requests for verification data

Incident Response

  • Report security issues to security@pylonid.eu
  • Revoke compromised API keys immediately via POST /v1/auth/rotate
  • PylonID will assist investigations if the platform is affected

Security Checklist

  • API key in secrets manager
  • Webhook signature validation implemented
  • HTTPS webhook endpoint with valid certificate
  • Key rotation process documented
  • Privacy policy updated
  • Data retention policies defined
  • Monitoring and alerting configured

Security contact: security@pylonid.eu General questions: hello@pylonid.eu

Troubleshooting & FAQ


Verification Issues

Wallet doesn’t open when scanning QR code

  • Desktop browser? EUDI wallet deep links only work on mobile
  • Wallet installed? User needs an EUDI wallet app
  • QR code content: Should contain the full eudi-openid4vp://authorize?request_uri=... URL
  • Try direct link: On mobile, open the walletUrl directly instead of scanning

Verification stays “pending”

  • User hasn’t scanned the QR code yet
  • User scanned but hasn’t consented
  • Verification expired (check expiresAt in response)
  • Start a new verification if expired

Verification times out

Verifications expire after the time shown in expiresAt. If the user doesn’t respond:

  • Show a “Try again” button
  • Create a new verification request
  • Don’t charge or penalize the user

Authentication Issues

401 Unauthorized

# Is your API key set?
echo $PYLON_API_KEY

# Is it in the right header format?
curl -H "Authorization: Bearer $PYLON_API_KEY" https://pylonid.eu/health

Common causes:

  • Missing Bearer prefix in Authorization header
  • Extra whitespace or newline in API key
  • Key was rotated (old key invalidated)
  • Key not yet created — run POST /v1/auth/signup first

400 Bad Request

  • Check JSON syntax (valid JSON?)
  • minAge must be between 1 and 150
  • callbackUrl must be HTTPS (not HTTP)

429 Too Many Requests

Rate limiting is active. Back off and retry with exponential delay:

async function withRetry(fn, maxAttempts = 3) {
  for (let i = 0; i < maxAttempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (err.status !== 429 || i === maxAttempts - 1) throw err;
      await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
    }
  }
}

Webhook Issues

Webhook never fires

  1. Is callbackUrl HTTPS? HTTP is rejected.
  2. Is it publicly reachable? Test: curl -X POST https://yourapp.com/webhook
  3. Does it return HTTP 2xx? Non-2xx triggers retries.
  4. Is verification still pending? Check: GET /v1/status/:id
  5. Using localhost? Use ngrok to expose local endpoints during development.

Signature validation fails

Most common cause: validating parsed JSON instead of raw body bytes.

// ❌ WRONG
app.post('/webhook', express.json(), (req, res) => {
  validate(JSON.stringify(req.body), signature); // Re-serialized — different bytes
});

// ✅ RIGHT
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  validate(req.body, signature); // Original bytes
});

Also check:

  • Correct webhook secret (no extra whitespace)
  • Header name is X-Pylon-Signature (case-insensitive in most frameworks)
  • Signature format is sha256={hex} — compare the whole string

Webhook times out

Your handler takes too long. Return 200 immediately:

app.post('/webhook', (req, res) => {
  res.status(200).json({ received: true }); // Return first
  queue.add('process', req.body);            // Process later
});

Local Emulator Issues

Build fails

# Ensure Rust 1.75+
rustc --version

# Clean and rebuild
cd pylon_cli
cargo clean
cargo build --release

Port 7777 in use

lsof -i :7777
kill -9 <PID>

Emulator requests fail

# Is it running?
curl http://localhost:7777/health

If connection refused, the emulator isn’t running. Start it from the pylon_cli directory.


Credential Issues

“Credential invalid” in webhook

The wallet sent an invalid or tampered credential. Ask the user to:

  • Update their wallet app
  • Re-issue the credential from their government provider
  • Try again

“Policy mismatch” in webhook

The user’s credential doesn’t meet the policy (e.g., user is 17, policy requires 18). Show a clear message: “You must be at least 18.”


Getting Help

ChannelUse for
hello@pylonid.euGeneral questions
security@pylonid.euSecurity issues
GitHub IssuesBug reports
GitHub DiscussionsQuestions and feature requests
pylonid.eu/statusService status

Wallet Interoperability

PylonID works with any EUDI wallet that implements the required standards.


Required Standards

Any compliant wallet must support:

StandardPurpose
OpenID4VPVerifiable presentation protocol
SD-JWT-VCSelective disclosure credentials
ES256Signature algorithm (ECDSA P-256)

PylonID uses the eudi-openid4vp:// URI scheme for wallet invocation, as specified in the EUDI Architecture Reference Framework.


Wallet Landscape

EUDI Reference Wallet

The EU-funded reference implementation. PylonID is built and tested against this wallet.

Government Wallets

EU member states are deploying national EUDI wallets under eIDAS 2.0:

CountryStatusNotes
GermanyPilotBDr Wallet
AustriaDevelopmentBuilding on eID Austria
ItalyDevelopmentIO App integration
OthersPlannedDec 2026 deadline for all member states

Commercial Wallets

ProviderStatus
LissiDeveloping EUDI support
VerimiDeveloping EUDI support
walt.idDeveloping EUDI support

Interoperability depends on each wallet’s OID4VP and SD-JWT-VC compliance. Test with each wallet before production use.


What PylonID Validates

Regardless of which wallet presents the credential, PylonID verifies:

  1. Credential format — valid SD-JWT-VC
  2. Credential typeurn:eudi:pid:1 (EU Person Identification Data)
  3. Issuer signature — ES256 verified against issuer JWKS
  4. Key binding — wallet proves credential ownership
  5. Freshness — presentation created within 5-minute window
  6. Nonce — matches the original request (replay protection)
  7. Disclosed claimsage_over_18 present and valid

Testing Recommendations

  1. Start with the emulator — deterministic, no wallet needed
  2. Test with EUDI reference wallet — the baseline implementation
  3. Test with target wallets — whichever wallets your users will have
  4. Monitor in production — track success/failure rates per wallet type

Conformance


Roadmap

  • Q3 2026: Test against commercial wallets (Lissi, Verimi, walt.id)
  • Q4 2026: Track and publish wallet compatibility matrix
  • 2027: EU member state wallets mandatory — broad testing

Wallet not working? Report via GitHub Issues or email hello@pylonid.eu

Changelog

v2.0.0 (2026-04-21) — EUDI Compliance

Phase 5 complete. Full OpenID4VP age verification with real EUDI wallet support.

Added

  • ✅ OpenID4VP authorization request objects (signed ES256 JWTs)
  • ✅ Wallet URL generation (eudi-openid4vp:// deep link scheme)
  • ✅ Request URI endpoint (GET /v1/oid4vp/request/:id)
  • ✅ VP Token response endpoint (POST /v1/oid4vp/response)
  • ✅ Verifier metadata discovery (GET /.well-known/openid-credential-verifier)
  • ✅ SD-JWT-VC parser (issuer JWT + disclosures + key binding JWT)
  • ✅ ES256 signature verification against PID Issuer JWKS
  • ✅ Key Binding JWT verification (nonce, audience, freshness)
  • ✅ Selective disclosure claim reconstruction
  • ✅ Integrated EUDI PID Issuer (Keycloak 26 + reference issuer)
  • ✅ JWKS fetching and caching (TTL-based)
  • ✅ API key authentication (POST /v1/auth/signup, POST /v1/auth/rotate)
  • ✅ Stable verifier and issuer signing keys across restarts

Changed

  • Wallet URL format changed from https://wallet.pylonid.eu/... to eudi-openid4vp://authorize?request_uri=...
  • Webhook signature format is sha256={hex} (HMAC-SHA256 of raw body)
  • Response from POST /v1/verify/age now includes requestUri and real wallet deep link
  • All endpoints served from pylonid.eu (no separate api.pylonid.eu)

Infrastructure

  • Caddy reverse proxy routing (API + website + EUDI stack)
  • Keycloak 26 with pid-issuer-realm
  • EUDI PID Issuer with PKCS12 keystore for stable signing keys
  • PostgreSQL 16 with AES-256-GCM encryption at rest
  • Docker Compose deployment (4 containers)

Known Limitations

  • No credential revocation checking
  • No presentation_submission validation
  • Database schema may change between versions
  • No official SDKs — use direct HTTP

v1.0.0 (2025-11-06) — Public Beta Launch

Added

  • ✅ Age verification API (POST /v1/verify/age)
  • ✅ Webhook delivery with exponential backoff retries
  • ✅ HMAC-SHA256 webhook signatures
  • ✅ PostgreSQL persistence
  • ✅ Health check endpoint
  • ✅ Local emulator (pylon-cli)

Known Limitations

  • Signature validation was structural only (mock credentials)
  • No API key authentication
  • No rate limiting enforcement

Infrastructure

  • PostgreSQL (self-hosted, Germany)
  • Docker deployment with Caddy reverse proxy
  • Webhook retry: exponential backoff

Release Cycle

Check your version:

curl https://pylonid.eu/health

Breaking changes announced in advance via GitHub releases.


Updates: Watch github.com/pylon-id/pylon for releases.

SDKs

Official SDKs are planned for Q4 2026. Until then, integrate directly via HTTP — the API is intentionally simple.

LanguagePackageStatus
Gogithub.com/pylon-id/sdk-goPlanned (Q4 2026)
JavaScript/TypeScript@pylon-id/sdkPlanned (Q4 2026)
Pythonpylon-idPlanned (Q4 2026)
Javacom.pylonid:sdkPlanned (Q4 2026)
Rustpylon-sdkPlanned (Q4 2026)

Each SDK page includes complete working examples for direct HTTP integration — calling the API, handling webhooks, and validating signatures.


Integration Pattern

Every language follows the same pattern:

  1. Call POST /v1/verify/age with your API key, policy, and callback URL
  2. Get walletUrl from the response — display as QR code
  3. Customer scans with EUDI wallet and consents
  4. PylonID POSTs result to your callbackUrl
  5. Validate the X-Pylon-Signature header (HMAC-SHA256)
  6. Process the verification result

Webhook Signature

Every webhook includes an X-Pylon-Signature header:

X-Pylon-Signature: sha256=a1b2c3d4e5f6...

This is HMAC-SHA256(your_webhook_secret, raw_request_body) formatted as sha256={hex}.

Always validate signatures and always use the raw request body (before JSON parsing). See each SDK page for language-specific examples, or the Webhooks guide for full details.


Environment Variables

export PYLON_API_KEY="pyl_..."
export PYLON_WEBHOOK_SECRET="your-webhook-secret"

Questions? See API Reference or email hello@pylonid.eu

Go Integration

Direct HTTP integration with Go’s standard library.


Start a Verification

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
  "os"
)

type VerifyRequest struct {
  Policy      Policy `json:"policy"`
  CallbackURL string `json:"callbackUrl"`
}

type Policy struct {
  MinAge int `json:"minAge"`
}

type VerifyResponse struct {
  VerificationID string `json:"verificationId"`
  Status         string `json:"status"`
  WalletURL      string `json:"walletUrl"`
  RequestURI     string `json:"requestUri"`
  ExpiresAt      string `json:"expiresAt"`
}

func main() {
  body, _ := json.Marshal(VerifyRequest{
    Policy:      Policy{MinAge: 18},
    CallbackURL: "https://yourapp.com/webhooks/pylon",
  })

  req, _ := http.NewRequest("POST", "https://pylonid.eu/v1/verify/age", bytes.NewBuffer(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Authorization", "Bearer "+os.Getenv("PYLON_API_KEY"))

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  var result VerifyResponse
  json.NewDecoder(resp.Body).Decode(&result)

  fmt.Printf("Verification: %s\n", result.VerificationID)
  fmt.Printf("Wallet URL:   %s\n", result.WalletURL)
  // Display result.WalletURL as QR code
}

Handle Webhooks

package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "encoding/json"
  "io"
  "net/http"
  "os"
)

func validateSignature(body []byte, signature, secret string) bool {
  h := hmac.New(sha256.New, []byte(secret))
  h.Write(body)
  computed := "sha256=" + hex.EncodeToString(h.Sum(nil))
  return hmac.Equal([]byte(signature), []byte(computed))
}

type WebhookPayload struct {
  Event          string                 `json:"event"`
  VerificationID string                 `json:"verificationId"`
  Status         string                 `json:"status"`
  Result         map[string]interface{} `json:"result"`
  Timestamp      string                 `json:"timestamp"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
  signature := r.Header.Get("X-Pylon-Signature")
  body, _ := io.ReadAll(r.Body)
  secret := os.Getenv("PYLON_WEBHOOK_SECRET")

  if !validateSignature(body, signature, secret) {
    http.Error(w, "Invalid signature", http.StatusUnauthorized)
    return
  }

  var payload WebhookPayload
  json.Unmarshal(body, &payload)

  if payload.Status == "verified" {
    // Grant access
    fmt.Printf("✅ %s: verified\n", payload.VerificationID)
  } else {
    fmt.Printf("❌ %s: %s\n", payload.VerificationID, payload.Status)
  }

  w.WriteHeader(http.StatusOK)
  w.Write([]byte(`{"received":true}`))
}

func main() {
  http.HandleFunc("/webhooks/pylon", webhookHandler)
  http.ListenAndServe(":3000", nil)
}

Error Handling

resp, err := http.DefaultClient.Do(req)
if err != nil {
  fmt.Fprintf(os.Stderr, "Network error: %v\n", err)
  return
}

switch resp.StatusCode {
case 200:
  // Success
case 401:
  fmt.Fprintln(os.Stderr, "Invalid API key")
case 400:
  fmt.Fprintln(os.Stderr, "Invalid request (check JSON, callbackUrl must be HTTPS)")
case 429:
  fmt.Fprintln(os.Stderr, "Rate limited — back off and retry")
default:
  fmt.Fprintf(os.Stderr, "Unexpected: %d\n", resp.StatusCode)
}

Reference: API Reference | Webhooks | Troubleshooting

JavaScript / TypeScript Integration

Direct HTTP integration with fetch (Node.js 18+, Deno, Bun, or browser).


Start a Verification

const response = await fetch('https://pylonid.eu/v1/verify/age', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${process.env.PYLON_API_KEY}`
  },
  body: JSON.stringify({
    policy: { minAge: 18 },
    callbackUrl: 'https://yourapp.com/webhooks/pylon'
  })
});

const data = await response.json();
console.log('Verification:', data.verificationId);
console.log('Wallet URL:', data.walletUrl);
// Display data.walletUrl as QR code

Handle Webhooks (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();

function validateSignature(body, signature, secret) {
  const computed = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed));
}

app.post('/webhooks/pylon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-pylon-signature'];

  if (!validateSignature(req.body, signature, process.env.PYLON_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body);

  if (payload.status === 'verified') {
    console.log(`✅ ${payload.verificationId}: verified`);
  } else {
    console.log(`❌ ${payload.verificationId}: ${payload.status}`);
  }

  res.status(200).json({ received: true });
});

app.listen(3000);

Handle Webhooks (Next.js API Route)

// app/api/webhooks/pylon/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';

function validateSignature(body: Buffer, signature: string, secret: string): boolean {
  const computed = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed));
}

export async function POST(req: NextRequest) {
  const body = Buffer.from(await req.arrayBuffer());
  const signature = req.headers.get('x-pylon-signature') ?? '';
  const secret = process.env.PYLON_WEBHOOK_SECRET!;

  if (!validateSignature(body, signature, secret)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const payload = JSON.parse(body.toString());

  if (payload.status === 'verified') {
    // Grant access
  }

  return NextResponse.json({ received: true });
}

TypeScript Types

interface VerifyAgeRequest {
  policy: { minAge: number };
  callbackUrl: string;
}

interface VerifyAgeResponse {
  verificationId: string;
  status: string;
  walletUrl: string;
  requestUri: string;
  expiresAt: string;
}

interface WebhookPayload {
  event: 'verification.completed';
  verificationId: string;
  status: 'verified' | 'rejected' | 'expired' | 'error';
  result: { age_over_18?: boolean };
  timestamp: string;
}

Error Handling

const response = await fetch('https://pylonid.eu/v1/verify/age', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${process.env.PYLON_API_KEY}`
  },
  body: JSON.stringify({
    policy: { minAge: 18 },
    callbackUrl: 'https://yourapp.com/webhooks/pylon'
  })
});

if (!response.ok) {
  switch (response.status) {
    case 401: console.error('Invalid API key'); break;
    case 400: console.error('Invalid request'); break;
    case 429: console.error('Rate limited'); break;
    default:  console.error(`Error: ${response.status}`); break;
  }
}

Reference: API Reference | Webhooks | Troubleshooting

Python Integration

Direct HTTP integration with the requests library.


Start a Verification

import os
import requests

response = requests.post(
    'https://pylonid.eu/v1/verify/age',
    json={
        'policy': {'minAge': 18},
        'callbackUrl': 'https://yourapp.com/webhooks/pylon'
    },
    headers={
        'Authorization': f"Bearer {os.getenv('PYLON_API_KEY')}"
    }
)

data = response.json()
print(f"Verification: {data['verificationId']}")
print(f"Wallet URL:   {data['walletUrl']}")
# Display data['walletUrl'] as QR code

Handle Webhooks (Flask)

import hmac, hashlib, os
from flask import Flask, request

app = Flask(__name__)

def validate_signature(body, signature, secret):
    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, computed)

@app.route('/webhooks/pylon', methods=['POST'])
def pylon_webhook():
    signature = request.headers.get('X-Pylon-Signature', '')
    body = request.get_data()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    if not validate_signature(body, signature, secret):
        return {'error': 'Invalid signature'}, 401

    data = request.json

    if data['status'] == 'verified':
        print(f"✅ {data['verificationId']}: verified")
    else:
        print(f"❌ {data['verificationId']}: {data['status']}")

    return {'received': True}, 200

if __name__ == '__main__':
    app.run(port=3000)

Handle Webhooks (FastAPI)

import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def validate_signature(body: bytes, signature: str, secret: str) -> bool:
    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, computed)

@app.post('/webhooks/pylon')
async def pylon_webhook(request: Request):
    signature = request.headers.get('X-Pylon-Signature', '')
    body = await request.body()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    if not validate_signature(body, signature, secret):
        raise HTTPException(status_code=401, detail='Invalid signature')

    data = await request.json()

    if data['status'] == 'verified':
        print(f"✅ {data['verificationId']}: verified")

    return {'received': True}

Error Handling

import requests, os

response = requests.post(
    'https://pylonid.eu/v1/verify/age',
    json={
        'policy': {'minAge': 18},
        'callbackUrl': 'https://yourapp.com/webhooks/pylon'
    },
    headers={'Authorization': f"Bearer {os.getenv('PYLON_API_KEY')}"}
)

if response.status_code == 401:
    print('Invalid API key')
elif response.status_code == 400:
    print(f'Invalid request: {response.json()}')
elif response.status_code == 429:
    print('Rate limited — back off and retry')
elif response.ok:
    data = response.json()
    print(f"Wallet URL: {data['walletUrl']}")

Reference: API Reference | Webhooks | Troubleshooting

Java Integration

Direct HTTP integration with Java’s built-in HTTP client (Java 11+).


Start a Verification

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PylonExample {
    public static void main(String[] args) throws Exception {
        String apiKey = System.getenv("PYLON_API_KEY");

        String json = """
            {
                "policy": { "minAge": 18 },
                "callbackUrl": "https://yourapp.com/webhooks/pylon"
            }
            """;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://pylonid.eu/v1/verify/age"))
            .header("Content-Type", "application/json")
            .header("Authorization", "Bearer " + apiKey)
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.body());
        // Parse JSON, display walletUrl as QR code
    }
}

Handle Webhooks (Spring Boot)

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@RestController
public class WebhookController {

    private boolean validateSignature(String body, String signature, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));

            StringBuilder sb = new StringBuilder("sha256=");
            for (byte b : hash) sb.append(String.format("%02x", b));

            return sb.toString().equals(signature);
        } catch (Exception e) {
            return false;
        }
    }

    @PostMapping("/webhooks/pylon")
    public ResponseEntity<?> handleWebhook(
        @RequestHeader("X-Pylon-Signature") String signature,
        @RequestBody String body
    ) {
        String secret = System.getenv("PYLON_WEBHOOK_SECRET");

        if (!validateSignature(body, signature, secret)) {
            return ResponseEntity.status(401).build();
        }

        // Parse body and process
        System.out.println("Webhook received: " + body);
        return ResponseEntity.ok(Map.of("received", true));
    }
}

Error Handling

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());

switch (response.statusCode()) {
    case 200 -> System.out.println("Success: " + response.body());
    case 401 -> System.err.println("Invalid API key");
    case 400 -> System.err.println("Invalid request");
    case 429 -> System.err.println("Rate limited — back off and retry");
    default  -> System.err.println("Error: " + response.statusCode());
}

Reference: API Reference | Webhooks | Troubleshooting

Rust Integration

Direct HTTP integration with reqwest.


Dependencies

[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
subtle = "2"

Start a Verification

use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct VerifyRequest {
    policy: Policy,
    #[serde(rename = "callbackUrl")]
    callback_url: String,
}

#[derive(Serialize)]
struct Policy {
    #[serde(rename = "minAge")]
    min_age: u32,
}

#[derive(Deserialize)]
struct VerifyResponse {
    #[serde(rename = "verificationId")]
    verification_id: String,
    #[serde(rename = "walletUrl")]
    wallet_url: String,
    #[serde(rename = "requestUri")]
    request_uri: String,
    #[serde(rename = "expiresAt")]
    expires_at: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("PYLON_API_KEY")?;

    let resp: VerifyResponse = reqwest::Client::new()
        .post("https://pylonid.eu/v1/verify/age")
        .header("Authorization", format!("Bearer {api_key}"))
        .json(&VerifyRequest {
            policy: Policy { min_age: 18 },
            callback_url: "https://yourapp.com/webhooks/pylon".into(),
        })
        .send()
        .await?
        .json()
        .await?;

    println!("Verification: {}", resp.verification_id);
    println!("Wallet URL:   {}", resp.wallet_url);
    // Display resp.wallet_url as QR code

    Ok(())
}

Handle Webhooks (Axum)

use axum::{http::{HeaderMap, StatusCode}, routing::post, Json, Router};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

fn validate_signature(body: &[u8], signature: &str, secret: &str) -> bool {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(body);
    let computed = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
    computed.as_bytes().ct_eq(signature.as_bytes()).into()
}

async fn webhook_handler(
    headers: HeaderMap,
    body: String,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let signature = headers.get("x-pylon-signature")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;

    let secret = std::env::var("PYLON_WEBHOOK_SECRET")
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !validate_signature(body.as_bytes(), signature, &secret) {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let payload: serde_json::Value = serde_json::from_str(&body)
        .map_err(|_| StatusCode::BAD_REQUEST)?;

    println!("{}: {}", payload["verificationId"], payload["status"]);

    Ok(Json(serde_json::json!({"received": true})))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/webhooks/pylon", post(webhook_handler));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Error Handling

#![allow(unused)]
fn main() {
let resp = reqwest::Client::new()
    .post("https://pylonid.eu/v1/verify/age")
    .header("Authorization", format!("Bearer {api_key}"))
    .json(&request)
    .send()
    .await?;

match resp.status().as_u16() {
    200 => { /* success */ }
    401 => eprintln!("Invalid API key"),
    400 => eprintln!("Invalid request"),
    429 => eprintln!("Rate limited — back off and retry"),
    code => eprintln!("Unexpected: {code}"),
}
}

Reference: API Reference | Webhooks | Troubleshooting

Example: Age Verification

Complete working examples of age verification integration.


The Flow

1. User clicks "Verify age" in your app
2. Your backend calls POST /v1/verify/age
3. PylonID returns walletUrl
4. Your frontend displays walletUrl as QR code
5. User scans QR with EUDI wallet
6. Wallet shows: "Share age_over_18?"
7. User consents → wallet sends proof to PylonID
8. PylonID validates → fires webhook to your backend
9. Your app grants or denies access

Node.js + Express

import express from 'express';
import crypto from 'crypto';
import QRCode from 'qrcode';

const app = express();

// 1. Start verification
app.post('/api/verify-age', async (req, res) => {
  const response = await fetch('https://pylonid.eu/v1/verify/age', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.PYLON_API_KEY}`
    },
    body: JSON.stringify({
      policy: { minAge: 18 },
      callbackUrl: 'https://yourapp.com/api/webhooks/pylon'
    })
  });

  const data = await response.json();
  const qr = await QRCode.toDataURL(data.walletUrl);

  res.json({
    verificationId: data.verificationId,
    qrCode: qr,
    walletUrl: data.walletUrl
  });
});

// 2. Handle webhook
function validateSignature(body, signature, secret) {
  const computed = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed));
}

app.post('/api/webhooks/pylon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-pylon-signature'];

  if (!validateSignature(req.body, signature, process.env.PYLON_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const { verificationId, status, result } = JSON.parse(req.body);

  if (status === 'verified' && result.age_over_18) {
    console.log(`✅ ${verificationId}: age verified`);
    // Grant access in your database
  } else {
    console.log(`❌ ${verificationId}: ${status}`);
  }

  res.status(200).json({ received: true });
});

app.listen(3000);

Python + Flask

import os, hmac, hashlib, requests, qrcode, base64
from io import BytesIO
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/verify-age', methods=['POST'])
def start_verification():
    response = requests.post(
        'https://pylonid.eu/v1/verify/age',
        json={
            'policy': {'minAge': 18},
            'callbackUrl': 'https://yourapp.com/webhook/pylon'
        },
        headers={'Authorization': f"Bearer {os.getenv('PYLON_API_KEY')}"}
    )
    data = response.json()

    # Generate QR code
    qr = qrcode.make(data['walletUrl'])
    buf = BytesIO()
    qr.save(buf)
    qr_b64 = base64.b64encode(buf.getvalue()).decode()

    return jsonify({
        'verificationId': data['verificationId'],
        'qrCode': f'data:image/png;base64,{qr_b64}',
        'walletUrl': data['walletUrl']
    })

def validate_signature(body, signature, secret):
    computed = 'sha256=' + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, computed)

@app.route('/webhook/pylon', methods=['POST'])
def pylon_webhook():
    signature = request.headers.get('X-Pylon-Signature', '')
    body = request.get_data()
    secret = os.getenv('PYLON_WEBHOOK_SECRET')

    if not validate_signature(body, signature, secret):
        return {'error': 'Invalid signature'}, 401

    data = request.json

    if data['status'] == 'verified' and data.get('result', {}).get('age_over_18'):
        print(f"✅ {data['verificationId']}: age verified")
    else:
        print(f"❌ {data['verificationId']}: {data['status']}")

    return {'received': True}, 200

if __name__ == '__main__':
    app.run(port=5000)

Error Handling

const response = await fetch('https://pylonid.eu/v1/verify/age', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${process.env.PYLON_API_KEY}`
  },
  body: JSON.stringify({
    policy: { minAge: 18 },
    callbackUrl: 'https://yourapp.com/webhook'
  })
});

if (!response.ok) {
  switch (response.status) {
    case 401: console.error('Invalid API key'); break;
    case 400: console.error('Invalid request (callbackUrl must be HTTPS)'); break;
    case 429: console.error('Rate limited — retry later'); break;
    default:  console.error(`Error: ${response.status}`); break;
  }
  return;
}

const data = await response.json();
// Display data.walletUrl as QR code

Polling as Backup

If webhooks aren’t suitable, poll the verification status:

async function waitForResult(verificationId) {
  for (let i = 0; i < 60; i++) {
    const resp = await fetch(`https://pylonid.eu/v1/status/${verificationId}`, {
      headers: { 'Authorization': `Bearer ${process.env.PYLON_API_KEY}` }
    });
    const data = await resp.json();

    if (data.status !== 'pending') return data;
    await new Promise(r => setTimeout(r, 2000)); // Poll every 2s
  }
  return { status: 'timeout' };
}

Next: API Reference | Webhooks | Troubleshooting

Example: KYC Attribute Verification

Status: Planned for Q3 2026.


What This Will Do

Verify identity attributes (name, date of birth, address, nationality) from EUDI wallets — without storing raw PII.

1. Your app calls POST /v1/verify/kyc
2. User scans QR with EUDI wallet
3. Wallet shows: "Share name, address, date of birth?"
4. User consents → wallet sends selective disclosure proof
5. PylonID validates and fires webhook with verified attributes

Why It Matters

  • Privacy: User controls exactly what’s shared (selective disclosure)
  • Compliance: You receive verified attributes, not raw documents
  • Trust: Attributes are government-issued and cryptographically signed
  • GDPR: Easier data retention and deletion — no document copies

Planned API

curl -X POST https://pylonid.eu/v1/verify/kyc \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attributes": ["given_name", "family_name", "date_of_birth", "address"],
    "callbackUrl": "https://yourapp.com/webhooks/pylon"
  }'

Planned Webhook

{
  "event": "verification.completed",
  "verificationId": "ver_...",
  "status": "verified",
  "result": {
    "given_name": "Anna",
    "family_name": "Müller",
    "date_of_birth": "1990-05-15",
    "address": {
      "street_address": "Schulstr. 12",
      "postal_code": "10115",
      "locality": "Berlin",
      "country": "DE"
    }
  },
  "timestamp": "2026-09-15T10:00:00Z"
}

Current: Use Age Verification (available now) Reference: API Reference | Changelog

Example: Sign in with EUDI

Status: Planned for Q4 2026.


What This Will Do

“Sign in with EUDI” using standard OpenID Connect — like “Sign in with Google” but backed by a government-issued digital identity.

1. User clicks "Sign in with EUDI"
2. Your app redirects to PylonID OAuth authorize endpoint
3. User scans QR with EUDI wallet
4. User consents → PylonID redirects back with authorization code
5. Your app exchanges code for ID token
6. User is logged in

Why OIDC?

  • Standard: Industry-standard OpenID Connect — works with existing auth libraries
  • Familiar: Same flow as Google, Apple, or Microsoft login
  • Secure: OAuth 2.0 with PKCE
  • Private: No passwords, no social profile scraping

Planned Endpoints

EndpointPurpose
GET /.well-known/openid-configurationOIDC discovery
GET /oauth/authorizeStart auth flow
POST /oauth/tokenExchange code for tokens
GET /oauth/userinfoGet user attributes

Planned Flow

// 1. Redirect to PylonID
window.location.href = 'https://pylonid.eu/oauth/authorize?' +
  new URLSearchParams({
    client_id: 'your-client-id',
    redirect_uri: 'https://yourapp.com/callback',
    response_type: 'code',
    scope: 'openid age_over_18',
    code_challenge: pkceChallenge,
    code_challenge_method: 'S256'
  });

// 2. Exchange code (on your backend)
const tokens = await fetch('https://pylonid.eu/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: 'https://yourapp.com/callback',
    client_id: 'your-client-id',
    code_verifier: pkceVerifier
  })
}).then(r => r.json());

// tokens.id_token contains verified claims

Current: Use Age Verification (available now) Reference: API Reference | Changelog