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 integrate | 10 minutes |
| Learning curve | REST API, not cryptography |
| Data sovereignty | EU-only, self-hosted, no US sub-processors |
| Lock-in | None — standards-native (OpenID4VP, SD-JWT-VC) |
| Deployment | Self-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
- 5 minutes: Quickstart — verify your first age
- 15 minutes: Local Emulator — test without a wallet
- 30 minutes: Self-host — deploy your own instance
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
| Status | Meaning | Fix |
|---|---|---|
| 401 | Invalid or missing API key | Check Authorization: Bearer header |
| 400 | Invalid request body | Check JSON syntax, minAge range, callbackUrl is HTTPS |
| 429 | Rate limited | Back off and retry |
| 500 | Server error | Retry; 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 — understand OID4VP and SD-JWT-VC
- API Reference — all endpoints
- Webhooks — production reliability
- Local Emulator — offline development
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:
- Issuer creates a JWT with hashed claim placeholders (
_sdarray) - Each real claim value is in a separate disclosure (base64-encoded)
- Holder chooses which disclosures to reveal
- 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.jwkin 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:
- Issuer identity — credential must come from a configured trusted PID Issuer
- Cryptographic signature — issuer’s ES256 signature must verify against their published JWKS
- Credential type — must be
urn:eudi:pid:1(EU Person Identification Data) - Freshness — Key Binding JWT must be recent (5-minute window)
- 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
- Quickstart — verify your first age
- API Reference — all endpoints
- Webhooks — production webhook handling
- Security — encryption and compliance
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:
| Field | Type | Required | Description |
|---|---|---|---|
policy.minAge | integer | Yes | Minimum age (1–150) |
callbackUrl | string | Yes | HTTPS 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"
}
| Field | Description |
|---|---|
verificationId | Unique ID for this verification |
walletUrl | Deep link for EUDI wallet — display as QR code |
requestUri | HTTPS URL the wallet fetches the signed request from |
expiresAt | Verification 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_definitionrequestingage_over_18fromurn:eudi:pid:1response_uri— where the wallet sends the VP tokennonceandstatefor replay protectionclient_id— PylonID verifier identifier
POST /v1/oid4vp/response
The wallet submits the Verifiable Presentation token here.
Request body: application/x-www-form-urlencoded
| Field | Description |
|---|---|
vp_token | SD-JWT-VC with selective disclosures and key binding JWT |
state | Matches the state from the authorization request |
presentation_submission | JSON describing which credential satisfies which input descriptor |
Processing:
- Parse SD-JWT-VC (issuer JWT + disclosures + key binding JWT)
- Fetch issuer JWKS and verify ES256 signature
- Verify key binding JWT (nonce, audience, freshness)
- Reconstruct claims from selective disclosures
- Extract
age_over_18 - Update verification status
- 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-Signatureheader
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
walletUrlandverificationId - 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
| Aspect | Emulator | Production |
|---|---|---|
| URL | http://localhost:7777 | https://pylonid.eu |
| Wallet | Browser UI (Accept/Reject) | Real EUDI wallet app |
| Signatures | No SD-JWT-VC validation | Full ES256 + JWKS verification |
| Webhooks | Fires immediately, no retries | Retries with exponential backoff |
| Storage | In-memory (clears on restart) | PostgreSQL |
| Auth | No API key required | API key required |
| Webhook signing | No signature | HMAC-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:
- Switch to
https://pylonid.euwith a real API key - Add webhook signature validation (emulator doesn’t sign, production does)
- 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
| Status | Meaning |
|---|---|
verified | User proved age ≥ minimum |
rejected | User denied consent or credential didn’t meet policy |
expired | User didn’t respond before timeout |
error | Technical 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
- Get raw request body (bytes, before JSON parsing)
- Compute
HMAC-SHA256(your_webhook_secret, raw_body) - Format as
sha256={hex} - Compare to
X-Pylon-Signatureheader 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 1 hour |
| 5 | 24 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
- Is
callbackUrlHTTPS? (HTTP is rejected) - Is it publicly reachable? Test with
curlfrom another machine - Does it return HTTP 2xx?
- Check verification status via
GET /v1/status/:id— is it stillpending?
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:
- Issuer trust — credential must come from a configured PID Issuer
- Signature — ES256 signature verified against issuer’s JWKS
- Credential type — must be
urn:eudi:pid:1 - Key binding — wallet proves possession of credential private key
- Freshness — Key Binding JWT must be within 5-minute window
- 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
| Standard | Status |
|---|---|
| eIDAS 2.0 | Architecturally compliant (OpenID4VP, SD-JWT-VC) |
| GDPR | Designed for compliance (data minimization, EU-only) |
| ISO 27001 | Not certified (planned) |
| SOC 2 | Not 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
walletUrldirectly instead of scanning
Verification stays “pending”
- User hasn’t scanned the QR code yet
- User scanned but hasn’t consented
- Verification expired (check
expiresAtin 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
Bearerprefix in Authorization header - Extra whitespace or newline in API key
- Key was rotated (old key invalidated)
- Key not yet created — run
POST /v1/auth/signupfirst
400 Bad Request
- Check JSON syntax (valid JSON?)
minAgemust be between 1 and 150callbackUrlmust 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
- Is
callbackUrlHTTPS? HTTP is rejected. - Is it publicly reachable? Test:
curl -X POST https://yourapp.com/webhook - Does it return HTTP 2xx? Non-2xx triggers retries.
- Is verification still pending? Check:
GET /v1/status/:id - 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
| Channel | Use for |
|---|---|
| hello@pylonid.eu | General questions |
| security@pylonid.eu | Security issues |
| GitHub Issues | Bug reports |
| GitHub Discussions | Questions and feature requests |
| pylonid.eu/status | Service status |
Wallet Interoperability
PylonID works with any EUDI wallet that implements the required standards.
Required Standards
Any compliant wallet must support:
| Standard | Purpose |
|---|---|
| OpenID4VP | Verifiable presentation protocol |
| SD-JWT-VC | Selective disclosure credentials |
| ES256 | Signature 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:
| Country | Status | Notes |
|---|---|---|
| Germany | Pilot | BDr Wallet |
| Austria | Development | Building on eID Austria |
| Italy | Development | IO App integration |
| Others | Planned | Dec 2026 deadline for all member states |
Commercial Wallets
| Provider | Status |
|---|---|
| Lissi | Developing EUDI support |
| Verimi | Developing EUDI support |
| walt.id | Developing 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:
- Credential format — valid SD-JWT-VC
- Credential type —
urn:eudi:pid:1(EU Person Identification Data) - Issuer signature — ES256 verified against issuer JWKS
- Key binding — wallet proves credential ownership
- Freshness — presentation created within 5-minute window
- Nonce — matches the original request (replay protection)
- Disclosed claims —
age_over_18present and valid
Testing Recommendations
- Start with the emulator — deterministic, no wallet needed
- Test with EUDI reference wallet — the baseline implementation
- Test with target wallets — whichever wallets your users will have
- Monitor in production — track success/failure rates per wallet type
Conformance
- EUDI Wallet Conformance Tests: github.com/EWC-consortium/ewc-wallet-conformance
- PylonID’s verifier metadata is published at
/.well-known/openid-credential-verifier
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/...toeudi-openid4vp://authorize?request_uri=... - Webhook signature format is
sha256={hex}(HMAC-SHA256 of raw body) - Response from
POST /v1/verify/agenow includesrequestUriand real wallet deep link - All endpoints served from
pylonid.eu(no separateapi.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_submissionvalidation - 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.
| Language | Package | Status |
|---|---|---|
| Go | github.com/pylon-id/sdk-go | Planned (Q4 2026) |
| JavaScript/TypeScript | @pylon-id/sdk | Planned (Q4 2026) |
| Python | pylon-id | Planned (Q4 2026) |
| Java | com.pylonid:sdk | Planned (Q4 2026) |
| Rust | pylon-sdk | Planned (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:
- Call
POST /v1/verify/agewith your API key, policy, and callback URL - Get
walletUrlfrom the response — display as QR code - Customer scans with EUDI wallet and consents
- PylonID POSTs result to your
callbackUrl - Validate the
X-Pylon-Signatureheader (HMAC-SHA256) - 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
| Endpoint | Purpose |
|---|---|
GET /.well-known/openid-configuration | OIDC discovery |
GET /oauth/authorize | Start auth flow |
POST /oauth/token | Exchange code for tokens |
GET /oauth/userinfo | Get 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