What is PYLON?
PYLON is a developer-friendly API for European Digital Identity (EUDI) wallet integration. Instead of learning 500+ pages of cryptographic standards (OID4VC, SD-JWT VC, ISO 18013-5), you call simple REST endpoints.
One Endpoint. Three Outcomes.
POST /v1/verify/age
{
"policy": { "minAge": 18 },
"callbackUrl": "https://app.example.com/webhooks/pylon"
}
Returns:
verificationId: Unique ID for this requestwalletUrl: QR code for user to scan with EUDI wallet
User scans → Wallet presents credential → PYLON validates → Your webhook fires with result. That's it.
Core Features
- Age Verification: Selective disclosure for age gates
- KYC Reuse: Leverage verified attributes without storing PII
- OIDC Login: "Sign in with EUDI" via OpenID Connect
- Qualified Signatures: ETSI-compliant digital signatures
Why PYLON?
| Feature | PYLON |
|---|---|
| Time to integrate | 10 minutes |
| Learning curve | Minimal (REST API, not cryptography) |
| Data sovereignty | EU-only, no US sub-processors |
| Lock-in | None (standards-native, export guaranteed) |
| Developer DX | SDKs, emulator, Postman, docs |
eIDAS 2.0 Compliance
The European Digital Identity Regulation (eIDAS 2.0) mandates:
- Dec 2026: Member states provide EUDI Wallet to citizens
- Dec 2027: Financial, healthcare, and mobility sectors must accept EUDI Wallet
PYLON is built for this deadline. Start integrating now.
Next Steps
- 5 minutes: Read Quickstart
- 15 minutes: Try the Local Emulator
- 30 minutes: Deploy to Sandbox
Questions? Check Troubleshooting or email support@pylonid.eu
Quickstart: Verify Age in 10 Minutes
Verify attributes from EUDI wallets in minutes. This guide walks you through your first age verification request.
Prerequisites
- Create a free account at
https://dashboard.pylonid.eu - Generate an API key in the dashboard
- Add a webhook endpoint (e.g.,
https://app.example.com/webhooks/pylon) - (Optional) Install local emulator:
npm install -g pylon-cli && pylon dev
Base URLs
| Environment | URL |
|---|---|
| Sandbox | https://sandbox.api.pylonid.eu |
| Production | https://api.pylonid.eu |
All requests require Bearer token authentication:
Authorization: Bearer <YOUR_PYLON_API_KEY>
Step 1: Create a Verification Request
Call POST /v1/verify/age with your policy and callback URL:
curl -X POST https://sandbox.api.pylonid.eu/v1/verify/age \\
-H "Authorization: Bearer $PYLON_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"policy": {
"minAge": 18,
"evidence": ["national_eid", "mdoc_id"]
},
"callbackUrl": "https://app.example.com/webhooks/pylon"
}'
Step 2: Parse the Response
PYLON returns a verification ID and wallet URL:
{
"verificationId": "ver_123abc789",
"status": "pending",
"walletUrl": "https://pylon.link/123abc789"
}
Step 3: Redirect the User
Display the walletUrl as a QR code or direct link. On mobile, the EUDI wallet app opens automatically.
Your app should:
- Display QR code (or link) to
walletUrl - User scans with EUDI wallet app
- Wallet shows: "Verify age >= 18?" with Accept/Deny buttons
- User taps Accept/Deny
- Wallet sends cryptographic proof back to PYLON
Step 4: Receive the Webhook
Once the user completes the action, PYLON POSTs the result to your callbackUrl:
{
"verificationId": "ver_123abc789",
"type": "age",
"result": "verified",
"attributes": {
"ageOver18": true
},
"evidence": {
"issuer": "AT_GOV",
"credentialType": "SD-JWT VC",
"proofHash": "abc123def456...",
"issuedAt": "2025-01-12T10:23:11Z"
},
"audit": {
"traceId": "trace_xyz987"
}
}
Critical: Always verify the webhook signature (see Webhooks).
Verify the Webhook Signature
PYLON signs every webhook with HMAC-SHA256. Verify the X-PYLON-Signature header:
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
);
}
app.post('/webhooks/pylon', (req, res) => {
const signature = req.headers['x-pylon-signature'].split('v1=');[1]
const secret = process.env.PYLON_WEBHOOK_SECRET;
if (!verifyWebhookSignature(JSON.stringify(req.body), signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
console.log(`Verified: ${req.body.verificationId}`);
res.status(200).json({ received: true });
});
Python
import hmac
import hashlib
from flask import Flask, request, jsonify
def verify_webhook_signature(payload, signature, secret):
computed = hmac.new(
secret.encode(),
payload.encode() if isinstance(payload, str) else payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, computed)
@app.route('/webhooks/pylon', methods=['POST'])
def webhook():
signature = request.headers.get('X-Pylon-Signature', '').split('v1=')[1]
secret = os.getenv('PYLON_WEBHOOK_SECRET')
if not verify_webhook_signature(request.get_data(), signature, secret):
return {'error': 'Invalid signature'}, 401
data = request.json
print(f"Verified: {data['verificationId']}")
return {'received': True}, 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
)
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write(payload)
computed := 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")[3:] // Remove "v1="
body, _ := io.ReadAll(r.Body)
if !verifyWebhookSignature(body, signature, os.Getenv("PYLON_WEBHOOK_SECRET")) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received":true}`))
}
Test Locally Without a Real Wallet
Use the local emulator to test without internet or a real EUDI wallet:
npm install -g pylon-cli
pylon dev --wallet=mock --age=20
In another terminal:
curl -X POST http://localhost:8000/v1/verify/age \\
-H "Authorization: Bearer test_key_local" \\
-H "Content-Type: application/json" \\
-d '{
"policy": {"minAge": 18},
"callbackUrl": "http://localhost:3000/webhook"
}'
The emulator auto-completes the flow and triggers your webhook instantly.
Error Cases
| Error | Meaning | Fix |
|---|---|---|
| 401 Unauthorized | Invalid API key | Check $PYLON_API_KEY export |
| 400 Bad Request | Invalid policy | Check JSON syntax and minAge (0-150) |
| 429 Too Many Requests | Rate limited | Wait 60s and retry; check dashboard for usage |
Next Steps
- ✅ Successfully verified age in 10 minutes
- 📖 Read Core Concepts to understand OID4VP + SD-JWT
- 📚 Read API Reference for all endpoints
- 🔒 Read Webhooks for production reliability
- 🧪 Try Local Emulator for offline testing
Questions?
See Troubleshooting or email support@pylonid.eu
Core Concepts
PYLON abstracts three standards: OID4VP, SD-JWT VC, and ISO 18013-5. You don't need to know them to use PYLON, but understanding the fundamentals helps.
Verifiable Credentials (VCs)
A Verifiable Credential is a digitally signed claim about an attribute.
Example: Government issues a credential: "Anna Müller, born 1990, age > 18"
Properties:
- Issued by a trusted entity (e.g., Austrian government)
- Cryptographically signed (can't be forged)
- Expires (e.g., 5 years from issuance)
- Selective disclosure (reveal only what's needed—e.g., just "age > 18", not full birthdate)
OID4VP: Presentation Protocol
OID4VP = OpenID Connect for Verifiable Presentations
It's the protocol that says:
- Your app requests a presentation: "Prove your age >= 18"
- Wallet responds with a presentation (selective disclosure proof)
- Your app verifies the cryptographic proof
In PYLON: You call POST /v1/verify/age. PYLON handles OID4VP handshakes internally, verifying the selective disclosure SD-JWT proof.
SD-JWT VC: Self-Issued Credentials
SD-JWT = Selective Disclosure JSON Web Token
It's a JSON Web Token that allows the wallet holder to selectively reveal claims.
Example:
- Token contains: name, birthdate, address
- Wallet user chooses: reveal only "age > 18", hiding other claims
- Server receives cryptographic proof with only necessary claims
In PYLON: The server verifies SD-JWT signature and claims integrity automatically.
ISO 18013-5/7: Mobile Document Standard
ISO 18013 = International standard for mobile digital identity documents
Used by EUDI Wallets to manage government-issued IDs supporting offline, QR code scanning, and interoperability.
In PYLON: Currently supports ISO 18013-5. ISO 18013-7 support is planned.
Verification Flow
The flow when a user verifies age through PYLON:
1. Your app calls: POST /v1/verify/age
↓
2. PYLON generates OID4VP request
↓
3. User scans QR code with EUDI wallet app
↓
4. Wallet prompts: "Verify age >= 18?"
↓
5. User accepts
↓
6. Wallet sends SD-JWT selective disclosure proof
↓
7. PYLON validates cryptographic proof
↓
8. PYLON verifies issuer trust & credential validity
↓
9. PYLON checks compliance to policy (min age)
↓
10. PYLON sends webhook with verification result
The entire cryptography is handled server-side by PYLON.
Wallet Ecosystems
Supported wallet types:
1. Government EUDI Wallets
Issued by member states: Austria, Germany, Italy, Poland.
2. Commercial EUDI Wallets
Third-party wallets like Lissi, Verimi.
3. Upcoming Mobile Device Wallets
Native support planned in iOS/Android OS.
See Wallet Interoperability for details.
Importance of PYLON
- Based on open standards
- Neutral to wallet vendor
- Future-proof for mandatory EUDI compliance
- eIDAS 2.0 compliant from launch
Next Steps
- Try the Quickstart
- Deploy with Sandbox Guide
- Deep dive in API Reference
- Ensure production reliability with Webhooks
- Use Local Emulator for offline dev
Questions?
Refer to Troubleshooting or contact support@pylonid.eu.
API Reference
Base URL: https://pylonid.eu
Health Check
Status: ✅ Live
GET /health
Check if API is running.
Request: curl https://pylonid.eu/health
Response (200 OK):
{
"status": "ok",
"service": "pylon-server",
"version": "1.0.0",
"environment": "production"
}
Verify Age
Status: ✅ Beta (signature validation coming Nov 2025)
POST /v1/verify/age
Create an age verification request.
Request:
curl -X POST https://pylonid.eu/v1/verify/age \
-H "Content-Type: application/json" \
-d '{
"policy": {
"minAge": 18
},
"callbackUrl": "https://app.example.com/webhooks/pylon"
}'
Request Body:
{
"policy": {
"minAge": 18 // or 21, 25, etc.
},
"callbackUrl": "https://app.example.com/webhooks/pylon"
}
Response (200 OK):
{
"verificationId": "ver_abc123xyz",
"walletUrl": "https://wallet.pylonid.eu/request/ver_abc123xyz",
"expiresAt": "2025-11-07T08:15:00Z"
}
Next Steps:
- Redirect user to
walletUrl - User scans with EUDI wallet and presents credential
- PYLON validates and POSTs to
callbackUrl
Get Verification Status
Status: ✅ Live
GET /v1/status/
Check the status of a verification request.
Request:
curl https://pylonid.eu/v1/status/ver_abc123xyz
Response (200 OK):
{
"verificationId": "ver_abc123xyz",
"status": "pending", // or "completed"
"result": null, // or "verified", "not_verified"
"createdAt": "2025-11-06T08:00:00Z",
"expiresAt": "2025-11-07T08:00:00Z"
}
Webhook Signature Verification
Status: ✅ Live
When a verification completes, PYLON POSTs to your callbackUrl with:
Headers:
X-Pylon-Signature: sha256=abc123...Idempotency-Key: webhook_attempt_456Content-Type: application/json
Body:
{
"verificationId": "ver_abc123xyz",
"status": "completed",
"result": "verified", // or "not_verified"
"completedAt": "2025-11-06T08:05:00Z"
}
Verify signature in your webhook handler:
import hmac
import hashlib
def verify_signature(signature_header, body, webhook_secret):
expected = hmac.new(
webhook_secret.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header.replace("sha256=", ""), expected)
Idempotency:
- The
Idempotency-Keyheader is included in webhook requests. - Your webhook handler should use this key to deduplicate repeated webhook deliveries to avoid processing the same event multiple times.
Error Responses
401 Unauthorized
{
"error": "invalid_api_key",
"message": "API key not found or expired"
}
422 Unprocessable Entity
{
"error": "invalid_request",
"message": "callbackUrl must be HTTPS"
}
429 Too Many Requests
{
"error": "rate_limited",
"message": "Exceeded 100 requests/second"
}
500 Internal Server Error
{
"error": "internal_error",
"message": "Unexpected server error"
}
Rate Limits (Beta)
- Free tier: 1,000 verifications/month
- Request rate: 100 requests/second
- Webhook timeout: 30 seconds
- Webhook retries: 5 attempts over 60 seconds (exponential backoff)
Roadmap
✅ v1.0 (Now)
- Age verification (mock signatures)
- Webhook delivery + retries
- PostgreSQL persistence
🟡 v1.1 (Nov 2025)
- Real signature validation (German EUDI Wallet Sandbox)
- API key authentication
🟡 v2.0 (Q1 2026)
- KYC attribute verification
- OIDC Login
- Self-serve dashboard
- SLA guarantee (99.95% uptime)
Questions?
See Troubleshooting or email support@pylonid.eu
Sandbox vs Production
Guide to choosing the right environment and migrating when ready.
Environments
Sandbox
- URL:
https://pylonid.eu - Purpose: Testing before going live
- Data: Reset on demand (non-persistent)
- Wallets: Simulated and German EUDI Wallet Sandbox (expected end Nov 2025)
- Rate limit: 10,000 requests/month (free)
- Cost: Free
Production
- URL:
https://api.pylonid.eu - Purpose: Live applications
- Data: Persistent (production data)
- Wallets: Real government EUDI wallets (Austria, Germany, Italy, etc.)
- Rate limit: Depends on your plan
- Cost: Pay-as-you-go starting €0.10/request
When to Move to Production
✅ Ready for production when:
- ✅ Local testing with emulator is passing
- ✅ Sandbox testing with real wallet is passing
- ✅ Webhook signature validation working
- ✅ Error handling implemented
- ✅ Rate limiting handled
- ✅ API key rotation strategy in place
❌ NOT ready if:
- ❌ Still debugging locally (use emulator first)
- ❌ Webhook URL not HTTPS
- ❌ No error handling
- ❌ No retry logic
Testing Progression
1. Local Emulator (no network)
↓
2. Sandbox (simulated wallet)
↓
3. Sandbox + German Wallet (real wallet, sandbox backend)
↓
4. Production (real users, real wallets)
Migration Checklist
Step 1: Prepare
- Generate production API key in dashboard
- Update code to use production URL
- Test with production credentials locally
- Enable production logging
- Set up monitoring and alerting
Step 2: Soft Launch
- Deploy to production with feature flag (off by default)
- Start with 1% of users
- Monitor error rates (<0.1% target)
- Monitor webhook delivery (>99.9% target)
Step 3: Ramp Up
- 5% of users → observe for 24h
- 25% of users → observe for 24h
- 100% of users → full rollout
Step 4: Monitor
- Daily checks for 1 week
- Weekly checks thereafter
- Alert on error rate >1%
- Alert on webhook delivery <99%
API Key Management
Sandbox API Keys
- Used for testing
- Separate from production keys
- Safe for example commits (not for real use)
- Regenerate if exposed
Production API Keys
- Never commit to repo use environment variables only
- Rotate every 90 days
- Revoke immediately if compromised
- One key per environment per application
Best Practices
# ✅ Use environment variable for key retrieval securely
export PYLON_API_KEY=$(aws secretsmanager get-secret-value --secret-id pylon-api-key | jq -r '.SecretString')
# ❌ Avoid hardcoded keys
const apiKey = "pk_live_abc123xyz"; # Bad
# ❌ Avoid committing keys to repo
git add .env # Bad
Environment Config Example
// Node.js sample
const pylon = new PylonClient({
apiKey: process.env.PYLON_API_KEY,
baseUrl: process.env.PYLON_ENV === 'production'
? 'https://api.pylonid.eu'
: 'https://pylonid.eu', // same domain, env distinguished via API keys/session
});
# Python sample
import os
client = Client(
api_key=os.getenv('PYLON_API_KEY'),
base_url=os.getenv('PYLON_BASE_URL', 'https://pylonid.eu'),
)
Webhook URL Requirements
Webhook URLs must be:
- HTTPS (no HTTP allowed)
- Publicly accessible (not localhost)
- Respond within 10 seconds (HTTP 200)
- Use valid SSL certificates
Testing Webhooks Locally
Use tools like ngrok to expose local endpoints:
ngrok http 3000
# Forwards https://abc123.ngrok.io → localhost:3000
# Use this URL as webhook endpoint in your app
Data Retention
Sandbox
- Verification and webhook logs kept for 7 days
- Manual reset allowed via dashboard
Production
- Verification data kept for 90 days (configurable)
- Webhook logs kept 30 days
- Data deletion compliant with GDPR on request
Support
- Sandbox issues via support@pylonid.eu or GitHub issues
- Production issues via priority support
- Incident reporting to security@pylonid.eu
Questions?
See Troubleshooting or email support@pylonid.eu ]
Local Testing with Emulator
Status: ✅ Production Ready
Quick Start
The local emulator lets you test age verification without needing a real EUDI wallet.
Prerequisites
# Ensure Rust 1.75+ is installed
rustc --version
# Build the emulator
cd ~/webstack/sites/pylon/pylon-cli
cargo build --release
Run the Emulator
# Start emulator on localhost:7777
./target/release/pylon-cli
Output:
✨ PYLON Emulator Starting...
🌐 Fake API: http://localhost:7777
👤 Fake Wallet: http://localhost:7777
📝 Ready for testing!
Test Workflow
Step 1: Create Age 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"
}
Save the verificationId for later.
Step 2: Open Fake Wallet UI
In your browser, visit:
http://localhost:7777/scan/ver_local_ABC123
You'll see a fake wallet interface with Accept and Reject buttons.
Step 3: Accept Verification
Click the Accept button. The emulator will:
- Create a test credential presentation
- Validate the age logic
- Fire a webhook to your callback URL
- Mark the verification as completed
Step 4: Receive Webhook
Your webhook endpoint at http://localhost:3000/webhook receives:
{
"verificationId": "ver_local_ABC123",
"type": "age",
"result": "verified",
"attributes": {
"ageOver18": true
},
"evidence": {
"issuer": "LOCAL_TEST",
"credentialType": "SD-JWT VC",
"proofHash": "sha256:test123...",
"issuedAt": "2025-01-15T14:30:00Z"
},
"audit": {
"traceId": "trace_ver_local_ABC123"
}
}
Test with Your App
Express.js Example
const express = require('express');
const app = express();
app.use(express.json());
// Step 1: Create verification request
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.redirect(data.walletUrl); // Redirect to fake wallet UI
});
// Step 2: Receive webhook
app.post('/webhook', (req, res) => {
const { verificationId, result } = req.body;
console.log(`✅ Verification ${verificationId}: ${result}`);
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('App running on :3000'));
Test it by running:
# In terminal 1, start your app
node app.js
# In terminal 2, start PYLON emulator
./target/release/pylon-cli
# In terminal 3, trigger verification
curl http://localhost:3000/start
Then:
- Browser opens fake wallet at
http://localhost:7777/scan/... - Click Accept
- See console log: ✅ Verification ver_local_ABC123: verified
Testing Webhook Retries
The emulator doesn't retry, but production API does. To test:
# Start a failing webhook server that returns 500
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
class FailHandler(BaseHTTPRequestHandler):
def do_POST(self):
self.send_response(500)
self.end_headers()
HTTPServer(('', 4000), FailHandler).serve_forever()
"
# Use callbackUrl pointing to failing server
curl -X POST http://localhost:7777/v1/verify/age \\
-d '{"policy":{"minAge":18},"callbackUrl":"http://localhost:4000/webhook"}'
Production retries with exponential backoff from 1s to 32s.
Emulator Features
| Feature | Behavior |
|---|---|
| Age Validation | Checks minAge against mock credential |
| Webhook Firing | Sends POST to callback URL immediately |
| State | In-memory, clears on restart |
| Retry | None (fires once immediately) |
| Signature | No signature validation |
Production Differences
| Aspect | Emulator | Production |
|---|---|---|
| URL | http://localhost:7777 | https://pylonid.eu |
| Wallet | Fake HTML UI | Real German EUDI wallet |
| Signatures | Mocked | Real OID4VP signature verification |
| Retry | None | Exponential backoff retries |
| Storage | In-memory | PostgreSQL persistent |
| Auth | None | API key required (Q1 2026) |
Troubleshooting
Port 7777 Already in Use
lsof -i :7777 # Find process using port
kill -9 <PID> # Kill blocking process
Webhook Not Firing
Test webhook endpoint locally:
curl -X POST http://localhost:3000/webhook \\
-H "Content-Type: application/json" \\
-d '{"test":true}'
Verification Not Found
Make sure verification ID matches format:
ver_local_XXXXXXXX
Next Steps
- Integrate official SDKs (Go, JS, Python, Rust, Java)
- Test error handling by sending invalid payloads
- Test webhooks for proper app behavior
- Move to production with real URLs
See API Reference for full API docs.
Questions?
See Troubleshooting or email support@pylonid.eu ]
Webhooks: Production Guide
PYLON delivers asynchronous verification results via webhooks. This guide covers production reliability, security, and best practices.
Overview
After a user completes a verification in their EUDI wallet, PYLON sends a signed HTTP POST to your webhook endpoint with the result.
Webhook Lifecycle
1. User completes verification (age, KYC, signature)
2. PYLON validates cryptographic proof
3. PYLON POSTs result to your callbackUrl
4. Your app receives and processes webhook
5. Your app returns HTTP 200
6. Webhook marked as delivered
Setup
1. Register Webhook Endpoint
In PYLON Dashboard:
- Go Settings > Webhooks
- Add endpoint:
https://app.example.com/api/webhooks/pylon - Copy Webhook Secret (save securely)
- Click "Send Test" to verify connectivity
Requirements
- ✅ HTTPS only (HTTP rejected)
- ✅ Publicly accessible (curl must work from internet)
- ✅ Returns HTTP 200 within 10 seconds
- ✅ Valid SSL certificate (Let's Encrypt OK)
2. Store Webhook Secret
# ✅ Good: Environment variable
export PYLON_WEBHOOK_SECRET="whsec_abc123xyz..."
# ❌ Bad: Hardcoded in code
const secret = "whsec_abc123xyz..."; # Don't do this!
Webhook Request Format
PYLON sends:
POST https://app.example.com/api/webhooks/pylon
Content-Type: application/json
X-PYLON-Signature: t=1678886400,v1=abcdef1234567890...
X-Pylon-Idempotency-Key: idem_123xyz789...
{
"verificationId": "ver_abc123xyz",
"type": "age",
"result": "verified",
"attributes": { ... },
"evidence": { ... },
"audit": { ... }
}
Headers
| Header | Purpose | Example |
|---|---|---|
X-PYLON-Signature | HMAC-SHA256 signature for verification | t=1678886400,v1=abcd... |
X-Pylon-Idempotency-Key | Unique key for retry deduplication | idem_123xyz789... |
Webhook Responses
Age Verification Result
{
"verificationId": "ver_abc123xyz",
"type": "age",
"result": "verified",
"attributes": {
"ageOver18": true
},
"evidence": {
"issuer": "AT_GOV",
"issuerName": "Republik Österreich",
"credentialType": "SD-JWT VC",
"proofHash": "sha256:abc123...",
"issuedAt": "2025-01-12T10:23:11Z",
"expiresAt": "2026-01-12T10:23:11Z"
},
"audit": {
"traceId": "trace_xyz987",
"timestamp": "2025-01-15T14:30:00Z"
}
}
KYC Verification Result
{
"verificationId": "ver_def456uvw",
"type": "kyc",
"result": "verified",
"attributes": {
"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"
}
},
"evidence": {
"issuer": "DE_GOV",
"credentialType": "SD-JWT VC",
"issuedAt": "2025-01-10T09:15:22Z"
},
"audit": {
"traceId": "trace_abc123",
"timestamp": "2025-01-15T14:30:00Z"
}
}
Failed Verification
{
"verificationId": "ver_ghi789jkl",
"type": "age",
"result": "not_verified",
"reason": "user_denied",
"audit": {
"traceId": "trace_failed123",
"timestamp": "2025-01-15T14:31:00Z"
}
}
Possible reasons:
user_denied— User tapped "Reject" in walletuser_cancelled— User closed wallet without respondingcredential_invalid— Wallet sent invalid/expired credentialpolicy_mismatch— Credential doesn't meet policy (e.g., user is 17, policy requires 18)timeout— User didn't respond within 15 minuteserror— Technical error (check traceId in logs)
Signature Validation (CRITICAL)
ALWAYS validate webhook signatures. Without validation, anyone can fake a webhook.
Signature Format
X-PYLON-Signature: t=1678886400,v1=abcdef1234567890abcdef1234567890
t= Unix timestamp when PYLON sent the webhookv1= HMAC-SHA256(secret, "t.body") in hex
Validation Algorithm
1. Extract t and v1 from header
2. Get raw request body (bytes, before JSON parsing)
3. Construct signed message: "t.{rawBody}"
4. Compute HMAC-SHA256(secret, signed_message)
5. Compare using timing-safe comparison
Node.js Example
import crypto from 'crypto';
function validatePylonWebhook(signature, body, secret) {
const [t, v1] = signature.split(',');
const tValue = t.replace('t=', '');
const signedMessage = `${tValue}.${body}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedMessage)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(v1.replace('v1=', '')),
Buffer.from(computed)
);
}
// Express middleware
app.post('/webhooks/pylon', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-pylon-signature'];
if (!validatePylonWebhook(signature, req.body.toString(), process.env.PYLON_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body);
handleWebhook(payload);
res.status(200).json({ received: true });
});
Python Example
import hmac
import hashlib
def validate_pylon_webhook(signature, body, secret):
t, v1 = signature.split(',')
t = t.replace('t=', '')
v1 = v1.replace('v1=', '')
signed_message = f"{t}.{body}"
computed = hmac.new(
secret.encode(),
signed_message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(v1, computed)
# Flask endpoint
@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_pylon_webhook(signature, body.decode(), secret):
return {'error': 'Invalid signature'}, 401
payload = request.json
handle_webhook(payload)
return {'received': True}, 200
Go Example
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func validatePylonWebhook(signature string, body []byte, secret string) bool {
parts := strings.Split(signature, ",")
if len(parts) != 2 {
return false
}
t := strings.TrimPrefix(parts[0], "t=")
v1 := strings.TrimPrefix(parts[1], "v1=")
signedMessage := t + "." + string(body)
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedMessage))
computed := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(v1), []byte(computed))
}
Idempotency & Deduplication
Every webhook retry includes the same X-Pylon-Idempotency-Key. Use this to prevent duplicate processing.
Why this matters: If your handler crashes after processing but before returning HTTP 200, PYLON retries. Without deduplication, you might grant access multiple times.
Node.js Example
app.post('/webhooks/pylon', async (req, res) => {
const idempotencyKey = req.headers['x-pylon-idempotency-key'];
// Check if already processed
const existing = await db.webhooks.findOne({ idempotencyKey });
if (existing) {
console.log(`Already processed: ${idempotencyKey}`);
return res.status(200).json({ status: 'already_processed' });
}
// Validate signature
if (!validatePylonWebhook(...)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Return 200 immediately (webhook delivered)
res.status(200).json({ received: true });
// Record processing
await db.webhooks.insertOne({
idempotencyKey,
verificationId: req.body.verificationId,
result: req.body.result,
processedAt: new Date(),
});
// Process asynchronously
queue.add('processWebhook', req.body);
});
With TTL (Auto-Cleanup)
// Store idempotency keys with 24-hour TTL
await db.webhooks.insertOne(
{
idempotencyKey,
verificationId: req.body.verificationId,
result: req.body.result,
processedAt: new Date(),
},
{
// MongoDB: auto-delete after 24 hours
expireAfterSeconds: 86400,
}
);
Retry Policy
If your webhook doesn't return HTTP 200 within 10 seconds, PYLON retries:
| Attempt | Delay | Total Time |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 30s | 30s |
| 3 | 5m | 5m 30s |
| 4 | 1h | 1h 5m 30s |
| 5 | 24h | 25h 5m 30s |
After the 5th attempt, webhook is marked as failed.
Best Practice: Process Async
// Return 200 immediately
res.status(200).json({ received: true });
// Process in background
setTimeout(() => {
// Do expensive work here (db updates, API calls)
processWebhook(payload);
}, 0);
This prevents retries during processing.
Common Issues
"My webhook never fires"
Checklist:
- Is callback URL HTTPS? (HTTP rejected)
- Is it publicly accessible?
curl https://app.example.com/webhook - Does it return HTTP 200 within 10 seconds?
- Check PYLON Dashboard > Webhooks > Delivery Log for error details
"Signature validation always fails"
Debugging:
# 1. Print raw request body (before JSON.parse)
console.log(typeof req.body); # Should be Buffer or string
# 2. Print signature header
console.log(req.headers['x-pylon-signature']);
# 3. Print secret
console.log(process.env.PYLON_WEBHOOK_SECRET);
# 4. Manual verify
const signed = `${t}.${body}`;
const manual = crypto.createHmac('sha256', secret).update(signed).digest('hex');
console.log('Expected:', v1);
console.log('Computed:', manual);
console.log('Match:', v1 === manual);
Mistake: Always use raw body bytes for signature validation; JSON parsing alters bytes.
"Webhook times out"
- Return HTTP 200 immediately and process asynchronously to prevent retries.
res.status(200).json({ received: true });
queue.add('process', payload); # Background job
"Webhook URL keeps rejecting connections"
- Check SSL certificate validity:
openssl s_client -connect app.example.com:443 - Check firewall rules and port accessibility
- Verify your service is up:
curl https://app.example.com/webhook
Monitoring
What to Track
app.post('/webhooks/pylon', (req, res) => {
const start = Date.now();
// ... process your webhook ...
const duration = Date.now() - start;
logger.info({
verificationId: req.body.verificationId,
result: req.body.result,
duration,
status: res.statusCode,
});
});
Metrics
- Delivery rate: % of webhooks returned HTTP 200
- Latency: time to process webhook
- Error rate: % of webhooks that failed or timed out
- Duplicate rate: % of duplicate idempotency keys received
Alerts
- Alert if delivery rate < 99%
- Alert if latency > 5s
- Alert if error rate > 0.5%
Questions?
See Troubleshooting or email support@pylonid.eu ]
Security & Compliance
PYLON is a privacy-first project designed and developed independently by a sole developer. This document describes implemented security measures, compliance goals, and your responsibilities.
Data Sovereignty
- All verification data processing is designed to occur within the EU.
- There are currently no external subprocessors or third-party data processors involved.
- Efforts are made to comply with EU data privacy regulations.
Compliance Status
- No formal certifications (ISO 27001, SOC 2, TISAX) or official audits have been obtained yet.
- The system is architected to follow standards such as eIDAS 2.0, OID4VP, SD-JWT, and ISO 18013.
- Plans for formal certification and audits are considered future goals.
Security Measures
- Transport encryption with TLS 1.3 minimum
- HMAC-SHA256 webhook signatures with replay protection
- API key-based authentication and planned rotation workflow
- Rate limiting to mitigate abuse
- Minimal attribute storage, retaining only verified flags and audit linkage
Developer & User Responsibilities
- Review suitability and compliance requirements for your use case.
- Store API keys securely and rotate them periodically.
- Use HTTPS-only webhook endpoints and validate all webhook signatures.
- Implement idempotent webhook processing to avoid duplicates.
- Respect data retention and GDPR requirements for personal data.
Audit & Logging
- Immutable audit trails recording verification events with anonymized IPs.
- Logs maintained for at least 1 year available via API.
- Recommendation to log webhook handling, access, and deletions.
Incident Response
- Report suspected breaches immediately to security@pylonid.eu.
- Revoke compromised API keys immediately.
- PYLON will assist investigations and communications if the platform is affected.
Security Checklist Prior to Production
- API keys secured and rotated
- Webhook security enforced with signatures and HTTPS
- Idempotency enforced on webhook processing
- Privacy policy updated to mention PYLON integration
- Data retention policies defined and implemented
- Monitoring and alerting configured
Contact & Support
- Security incidents: security@pylonid.eu
- Compliance queries: compliance@pylonid.eu
- General support: support@pylonid.eu
Questions?
See Troubleshooting or email support@pylonid.eu ]
Troubleshooting & FAQ
Quick solutions for common issues.
Verification Issues
"walletUrl not opening in wallet app"
Symptoms: User sees browser page, wallet doesn't open
Solutions:
- Check URL format: Should be
https://pylonid.eu/... - Test on mobile: Desktop browsers can't open wallet
- Wallet installed? Ask user to install EUDI wallet first
- QR code: If URL doesn't work, display QR code instead
- Deep linking: Some wallets require specific link format
"User sees 'Wallet not found'"
Cause: No compatible EUDI wallet installed
Solutions:
- Redirect user to wallet app store
- Support multiple wallets (Austria, Germany, Italy)
- Provide fallback (alternative verification method)
"Verification times out (user doesn't respond)"
Timeout: 15 minutes
Solutions:
- Extend timeout on your UI (show timer, refresh link)
- Allow user to start over (new verification request)
- Don't charge user if they time out
Authentication Issues
"401 Unauthorized"
Cause: Invalid or missing API key
Debug:
echo $PYLON_API_KEY # Is it set?
echo ${#PYLON_API_KEY} # Is it long enough? (>30 chars)
curl -H "Authorization: Bearer $PYLON_API_KEY" https://pylonid.eu/health
Solutions:
- Copy API key from dashboard again
- Check it's the correct environment (sandbox vs production)
- Ensure no whitespace/newlines in key
- Regenerate key if it's >90 days old
"403 Forbidden"
Cause: API key lacks permission
Solutions:
- Check key scope in dashboard (should have full access)
- Rotate key if permissions were recently changed
- Contact support@pylonid.eu if issue persists
Rate Limiting
"429 Too Many Requests"
Limits:
- Free tier: 1,000 ops/month
- Sandbox: 10,000 ops/month
- Pay-as-you-go: Unlimited (charged per request)
Solutions:
- Check usage: Dashboard > Usage
- Wait 60 seconds: PYLON resets limits hourly
- Upgrade tier: Paid plans have higher limits
- Batch requests: Combine multiple verifications if possible
Example retry logic:
async function retryWithBackoff(fn, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (error.status !== 429 || attempt === maxAttempts) {
throw error;
}
// Wait 2^attempt seconds
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Webhook Issues
"My webhook never fires"
Checklist:
-
Is callback URL HTTPS? (HTTP rejected)
bash curl https://app.example.com/webhook # Should return 200, not error -
Is it publicly accessible?
bash # Test from another machine curl https://app.example.com/webhook -
Does it return HTTP 200?
bash curl -i https://app.example.com/webhook # Look for "200 OK" in headers -
Check delivery log: Dashboard > Webhooks > Delivery Log - Shows attempted deliveries - Error messages if delivery failed
Solutions:
- Firewall blocking: Whitelist PYLON IPs (ask support)
- SSL certificate expired: Renew certificate
- Service down: Check uptime of your webhook server
- Timeout >10s: Your handler is too slow (see below)
"Webhook signature validation fails"
Debug steps:
// 1. Ensure you have raw body (before JSON.parse)
console.log(typeof req.body); // Should be Buffer or string
// 2. Print what you're validating
const signature = req.headers['x-pylon-signature'];
const body = req.body.toString();
const secret = process.env.PYLON_WEBHOOK_SECRET;
console.log('Signature:', signature);
console.log('Body length:', body.length);
console.log('Secret:', secret ? '***' : 'MISSING');
// 3. Manually verify
const [t, v1] = signature.split(',');
const tValue = t.replace('t=', '');
const v1Value = v1.replace('v1=', '');
const signedMsg = `${tValue}.${body}`;
const crypto = require('crypto');
const manual = crypto
.createHmac('sha256', secret)
.update(signedMsg)
.digest('hex');
console.log('Expected:', v1Value);
console.log('Computed:', manual);
console.log('Match:', v1Value === manual);
Common mistakes:
- ❌ Using
JSON.stringify(req.body)instead of raw body - ❌ Missing
t=orv1=prefix in parsing - ❌ Wrong secret (copy-pasted with extra spaces)
- ❌ Using older SDK with old signature format
"Webhook times out"
Symptoms: Webhook fires, but times out before your app responds
Cause: Handler takes >10 seconds
Solution: Return 200 immediately, process async:
app.post('/webhooks/pylon', async (req, res) => {
// Validate quickly
if (!validateSignature(...)) {
return res.status(401).send('Invalid');
}
// Return immediately (don't wait for processing)
res.status(200).json({ received: true });
// Process in background
queue.add('processWebhook', req.body);
});
Credential Issues
"Credential expired"
Error: User's credential is >5 years old
Cause: Government credential has expired
Solution:
- User must renew credential with government wallet
- Provide link to credential renewal
- Retry verification after renewal
"Credential doesn't meet policy"
Error: User is 17, policy requires 18
Result: Webhook shows result: "not_verified" with reason: "policy_mismatch"
Solution:
- Show friendly error: "You must be at least 18"
- Offer retry when they turn 18
"Credential invalid"
Cause: Wallet sent invalid/tampered credential
Result: Webhook shows result: "not_verified" with reason: "credential_invalid"
Solution:
- Ask user to try again
- Check their wallet is up to date
- Ask them to update wallet app
Testing Issues
"Local emulator not working"
# Install
npm install -g pylon-cli
# Start
pylon dev
# Should see:
# ✅ Issuer running on http://localhost:8001
# ✅ Wallet running on http://localhost:8002
# ✅ Proxy running on http://localhost:7777
If it fails:
# Kill existing process
lsof -i :7777 # Find what's using port
kill -9 <PID>
# Try again
pylon dev
"Emulator requests fail"
Check endpoints:
curl http://localhost:7777/v1/verify/age
# Should return 200, not connection error
If connection refused:
- Emulator not running (see above)
- Wrong port (check
pylon devoutput) - Firewall blocking localhost
Production Readiness
Checklist Before Going Live
- Local testing: Works with emulator
- Sandbox testing: Works with German wallet (Nov 2025+)
- Monitoring: Error rate tracked
- Logging: All webhooks logged
- Alerts: Notified if webhook delivery fails
- Handling: Error cases handled gracefully
- Security: API key stored securely, webhook validated
- Documentation: Team knows integration details
- Backup: Can handle PYLON outages (graceful fallback)
Getting Help
GitHub Issues:
- Bug reports: github.com/pylon-id/issues
- Feature requests: Label as
enhancement - Questions: github.com/pylon-id/discussions
Email Support:
- support@pylonid.eu (24h response)
- security@pylonid.eu (security issues)
- compliance@pylonid.eu (legal/compliance)
Community Discord:
- discord.gg/pylon-id
- Real-time help from team + developers
Status Page:
- pylonid.eu/status
- Real-time system status ]
Wallet Interoperability
PYLON supports major EUDI wallet standards and guides you through current testing and integration status.
Standards Compliance
All compliant EUDI wallets must support:
| Standard | Status | Purpose |
|---|---|---|
| OID4VP 1.0 | ✅ Final (Jun 2025) | Verifiable presentations protocol |
| SD-JWT VC | ✅ Standard | Selective disclosure credentials |
| ISO 18013-5/7 | ✅ Required | Mobile document format |
| FAPI 2 Baseline | ✅ Standard | OAuth 2.0 security |
Government Wallets
| Country | Wallet Name | Status | Testing Status | Notes |
|---|---|---|---|---|
| Austria | eID Austria | ✅ Live | ✅ Tested | Integrating EUDI features |
| Italy | IO App | ✅ Live | ✅ Tested | Includes EUDI credentials |
| Germany | BDr Wallet | 🔄 Sandbox | 🔄 Testing | Sandbox launch expected Nov 2025 |
| Greece | TBD | 🔄 Dev | ⏳ Pending | 2025-2026 rollout |
| Luxembourg | TBD | 🔄 Dev | ⏳ Pending | 2025-2026 rollout |
| Poland | TBD | 🔄 Dev | ⏳ Pending | 2025-2026 rollout |
Commercial Wallets
| Provider | Status | Notes |
|---|---|---|
| Lissi | 🔄 Beta | Testing OID4VP compliance |
| Verimi | 🔄 Beta | Testing SD-JWT support |
| walt.id | 🔄 Beta | Testing ISO 18013-5 |
Feature support varies—test in sandbox before production.
Wallet Service Domain Status
wallet.pylonid.eu is currently a placeholder with no active wallet service.
Use:
- Local emulator (
http://localhost:7777) for development - Sandbox environment with real wallets for integration tests
- Production environment with actual user wallets
until a dedicated wallet service is launched.
Testing Your Integration
Recommended sequence:
- Local emulator (pylon-cli): Instant and deterministic
- Sandbox with German wallet (Nov 2025+): Real wallet, test backend
- Production: Live users, production backend
Known Compatibility Notes
- OID4VP and selective disclosure are consistently supported
- Age > 18 verification supported
- HMAC-SHA256 webhook security standard enforced
- Attribute support and disclosure levels vary by wallet
Issue Resolution
Confirm issues via local emulator and sandbox.
For unresolved issues:
- Contact support@pylonid.eu
- Use priority support for production users with paid plans
Public Conformance & Contribution
- Conformance tests at github.com/EWC-consortium/ewc-wallet-conformance
- Quarterly results published
- Contribution via email, GitHub issues, Discord
Roadmap
- Q1 2026: Test against Lissi, Verimi, walt.id
- Q2 2026: Native iOS/Android EUDI wallet support
- Q3 2026: EU member state wallets in testing
Don’t see your wallet? Email support@pylonid.eu to request testing.
]
Changelog
v1.0.0 (2025-11-06) — Public Beta Launch
Added
- ✅ Age verification API (
POST /v1/verify/age) - ✅ Webhook delivery with exponential backoff retries
- ✅ Webhook signature validation (HMAC-SHA256)
- ✅ Idempotency keys for deduplication
- ✅ PostgreSQL persistence (data survives restarts)
- ✅ Health check endpoint
- ✅ Local emulator with mock wallet
Known Limitations (Beta)
- 🟡 Signature validation is structural only (mock credentials accepted)
- 🟡 Real signature validation launching Nov 25, 2025
- 🟡 No API key authentication (public sandbox)
- 🟡 No rate limiting enforcement
- 🟡 No self-serve dashboard (email signup only)
Infrastructure
- PostgreSQL database (self-hosted, Germany)
- Docker deployment with Caddy reverse proxy
- Data retention: 30 days (automatic cleanup)
- Webhook retry: 1s → 2s → 4s → 8s → 16s → 32s
Migration Notes
If upgrading from v0.1:
- Run new migrations:
migrations/20250206_003_webhook_schema_update.sql - Redeploy pylon-server
- Start cleanup job:
pylon-cleanup(background process)
Release Cycle
We release updates monthly. Check GitHub for latest version.
Check your version
curl https://pylonid.eu/health | grep version
All breaking changes announced 30 days in advance. ]
PYLON SDKs
Official SDKs for PYLON. Development in progress.
SDK Status
| Language | Package | Status |
|---|---|---|
| Go | github.com/pylon-id/sdk-go | 🔄 Planned |
| JavaScript/TypeScript | @pylon-id/sdk | 🔄 Planned |
| Python | pylon-id | 🔄 Planned |
| Rust | pylon-sdk | 🔄 Planned |
| Java | com.pylonid:sdk | 🔄 Planned |
All SDKs are under development. Direct API integration is currently recommended.
Current Integration Method
Use direct HTTP requests to the PYLON API until SDKs are released.
API Endpoint
{BASE_URL}
Local Testing
http://localhost:7777
Use the local emulator (pylon-cli) for development.
Common Integration Pattern
- Initialize HTTP client with API key
- Call
POST /v1/verify/agewith policy and callback URL - Get
walletUrlfrom response - Redirect user to
walletUrl - Receive webhook when verification completes
- Validate webhook signature (HMAC-SHA256)
- Process verification result
Environment Variables
export PYLON_API_KEY=<your-api-key>
export PYLON_WEBHOOK_SECRET=<your-webhook-secret>
Webhook Signature Validation
Critical: Always validate webhook signatures to prevent spoofed requests.
Validation Steps
- Extract
X-Pylon-Signatureheader - Get raw request body (bytes, before JSON parsing)
- Retrieve webhook secret from environment
- Compute HMAC-SHA256 signature
- Compare using timing-safe comparison
See Webhooks Guide for implementation examples in multiple languages.
Error Handling
Common error codes:
| Code | Meaning | Action |
|---|---|---|
INVALID_API_KEY | API key missing or invalid | Check environment variable |
INVALID_CALLBACK_URL | Callback not HTTPS | Use valid HTTPS URL |
NETWORK_ERROR | Network failure | Retry with backoff |
UNKNOWN_ERROR | Server error | Contact support if persists |
Webhook Reliability
PYLON provides at-least-once delivery with:
- Exponential backoff retries (1s → 2s → 4s → 8s → 16s → 32s)
- Timeout: 10 seconds per attempt
- Max retries: 5 attempts
- Idempotency via
X-Pylon-Idempotency-Keyheader
Return HTTP 200 to acknowledge receipt.
Support
- Questions: See Troubleshooting
- Issues: GitHub
- Email: support@pylonid.eu
Next Steps
- See API Reference for endpoint documentation
- Try Local Testing with the emulator
- Review Webhooks Guide for integration examples
Roadmap
- Q1 2026: Official SDK releases for all listed languages
- Q2 2026: Additional language support on request
See Changelog for updates.
Rust SDK
Status: 🔄 Planned. Not yet available.
Official Rust SDK for PYLON is under development. Use direct HTTP integration until released.
Current Integration (Direct HTTP)
Until the SDK is available, use reqwest:
[dependencies]
reqwest = {{ version = "0.11", features = ["json"] }}
tokio = {{ version = "1", features = ["full"] }}
serde = {{ version = "1.0", features = ["derive"] }}
serde_json = "1.0"
use reqwest; use serde::{{Deserialize, Serialize}}; use std::env; #[derive(Serialize)] struct VerifyAgeRequest {{ policy: AgePolicy, #[serde(rename = "callbackUrl")] callback_url: String, }} #[derive(Serialize)] struct AgePolicy {{ #[serde(rename = "minAge")] min_age: u32, }} #[derive(Deserialize)] struct VerifyAgeResponse {{ #[serde(rename = "verificationId")] verification_id: String, status: String, #[serde(rename = "walletUrl")] wallet_url: String, }} #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {{ let api_key = env::var("PYLON_API_KEY")?; let request = VerifyAgeRequest {{ policy: AgePolicy {{ min_age: 18 }}, callback_url: "https://app.example.com/webhooks/pylon".to_string(), }}; let client = reqwest::Client::new(); let resp = client .post("{BASE_URL}/v1/verify/age") .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {{}}", api_key)) .json(&request) .send() .await?; let result: VerifyAgeResponse = resp.json().await?; println!("Verification ID: {{}}", result.verification_id); println!("Wallet URL: {{}}", result.wallet_url); // Redirect user to result.wallet_url Ok(()) }}
Handle Webhooks (Axum)
use axum::{{ extract::State, http::{{HeaderMap, StatusCode}}, response::IntoResponse, routing::post, Json, Router, }}; use serde::{{Deserialize, Serialize}}; use hmac::{{Hmac, Mac}}; use sha2::Sha256; use hex; #[derive(Deserialize)] struct WebhookPayload {{ #[serde(rename = "verificationId")] verification_id: String, result: String, }} fn validate_signature(signature: &str, body: &str, secret: &str) -> bool {{ let parts: Vec<&str> = signature.split(',').collect(); if parts.len() != 2 {{ return false; }} let t = parts[0].trim_start_matches("t="); let v1 = parts[1].trim_start_matches("v1="); let signed_message = format!("{{}}.{{}}", t, body); type HmacSha256 = Hmac<Sha256>; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); mac.update(signed_message.as_bytes()); let computed = hex::encode(mac.finalize().into_bytes()); v1 == computed }} async fn pylon_webhook( 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(signature, &body, &secret) {{ return Err(StatusCode::UNAUTHORIZED); }} let payload: WebhookPayload = serde_json::from_str(&body) .map_err(|_| StatusCode::BAD_REQUEST)?; if payload.result == "verified" {{ println!("✅ Verified!"); return Ok(Json(serde_json::json!({{"received": true}}))); }} Ok(Json(serde_json::json!({{"received": true}}))) }} #[tokio::main] async fn main() {{ let app = Router::new() .route("/webhooks/pylon", post(pylon_webhook)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap(); }}
Required dependencies:
[dependencies]
axum = "0.7"
tokio = {{ version = "1", features = ["full"] }}
serde = {{ version = "1.0", features = ["derive"] }}
serde_json = "1.0"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
Idempotency Handling
#![allow(unused)] fn main() { use std::collections::HashSet; use std::sync::Mutex; // In production, use a database instead lazy_static::lazy_static! {{ static ref PROCESSED: Mutex<HashSet<String>> = Mutex::new(HashSet::new()); }} async fn pylon_webhook( headers: HeaderMap, body: String, ) -> Result<Json<serde_json::Value>, StatusCode> {{ let idempotency_key = headers .get("x-pylon-idempotency-key") .and_then(|v| v.to_str().ok()) .ok_or(StatusCode::BAD_REQUEST)?; // Check if already processed {{ let processed = PROCESSED.lock().unwrap(); if processed.contains(idempotency_key) {{ return Ok(Json(serde_json::json!({{"status": "already_processed"}}))); }} }} // Validate signature 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(signature, &body, &secret) {{ return Err(StatusCode::UNAUTHORIZED); }} // Store idempotency key {{ let mut processed = PROCESSED.lock().unwrap(); processed.insert(idempotency_key.to_string()); }} // Return 200 immediately Ok(Json(serde_json::json!({{"received": true}}))) // Process asynchronously (spawn background task) }} }
Error Handling
#![allow(unused)] fn main() { match client .post("{BASE_URL}/v1/verify/age") .header("Authorization", format!("Bearer {{}}", api_key)) .json(&request) .send() .await {{ Ok(resp) => match resp.status().as_u16() {{ 401 => eprintln!("❌ Invalid API key"), 429 => eprintln!("❌ Rate limited"), 400 => eprintln!("❌ Invalid request"), 200..=299 => println!("✅ Success"), code => eprintln!("❌ Error: {{}}", code), }}, Err(e) => eprintln!("❌ Network error: {{}}", e), }} }
Testing Locally
Start the local emulator:
pylon-cli
Point requests to localhost:
#![allow(unused)] fn main() { let resp = client .post("http://localhost:7777/v1/verify/age") .json(&request) .send() .await?; }
Roadmap
- Q1 2026: Official Rust SDK with async-first design using Tokio
Questions? See Troubleshooting or API Reference
JavaScript/TypeScript SDK
Status: 🔄 Planned. Not yet available.
Official JavaScript/TypeScript SDK for PYLON is under development. Use direct HTTP integration until released.
Current Integration (Direct HTTP)
Until the SDK is available, use native fetch or axios:
// Using fetch (Node.js 18+ or browser)
async function verifyAge() {{
const response = await fetch('{BASE_URL}/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://app.example.com/webhooks/pylon'
}})
}});
const data = await response.json();
console.log('Verification ID:', data.verificationId);
console.log('Wallet URL:', data.walletUrl);
// Redirect user to data.walletUrl
}}
verifyAge();
Handle Webhooks (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.json());
function validatePylonWebhook(signature, body, secret) {{
const [t, v1] = signature.split(',');
const tValue = t.replace('t=', '');
const v1Value = v1.replace('v1=', '');
const signedMessage = `${{tValue}}.${{body}}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedMessage)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(v1Value),
Buffer.from(computed)
);
}}
app.post('/webhooks/pylon', express.raw({{ type: 'application/json' }}), (req, res) => {{
const signature = req.headers['x-pylon-signature'];
const body = req.body.toString();
const secret = process.env.PYLON_WEBHOOK_SECRET;
if (!validatePylonWebhook(signature, body, secret)) {{
return res.status(401).json({{ error: 'Invalid signature' }});
}}
const payload = JSON.parse(body);
const {{ verificationId, result, attributes }} = payload;
if (result === 'verified') {{
console.log('✅ Verified!');
return res.json({{ received: true }});
}}
res.json({{ received: true }});
}});
app.listen(3000, () => console.log('Webhook server on port 3000'));
Handle Webhooks (Next.js API Route)
import crypto from 'crypto';
function validatePylonWebhook(signature, body, secret) {{
const [t, v1] = signature.split(',');
const tValue = t.replace('t=', '');
const v1Value = v1.replace('v1=', '');
const signedMessage = `${{tValue}}.${{body}}`;
const computed = crypto
.createHmac('sha256', secret)
.update(signedMessage)
.digest('hex');
return v1Value === computed;
}}
export default async function handler(req, res) {{
if (req.method !== 'POST') {{
return res.status(405).json({{ error: 'Method not allowed' }});
}}
const signature = req.headers['x-pylon-signature'];
const body = JSON.stringify(req.body);
const secret = process.env.PYLON_WEBHOOK_SECRET;
if (!validatePylonWebhook(signature, body, secret)) {{
return res.status(401).json({{ error: 'Invalid signature' }});
}}
const {{ verificationId, result }} = req.body;
if (result === 'verified') {{
return res.status(200).json({{ received: true }});
}}
return res.status(200).json({{ received: true }});
}}
Idempotency Handling
app.post('/webhooks/pylon', express.raw({{ type: 'application/json' }}), async (req, res) => {{
const idempotencyKey = req.headers['x-pylon-idempotency-key'];
// Check if already processed
const existing = await db.webhooks.findOne({{ idempotencyKey }});
if (existing) {{
return res.status(200).json({{ status: 'already_processed' }});
}}
// Validate signature
const signature = req.headers['x-pylon-signature'];
const body = req.body.toString();
if (!validatePylonWebhook(signature, body, process.env.PYLON_WEBHOOK_SECRET)) {{
return res.status(401).json({{ error: 'Invalid signature' }});
}}
// Store idempotency key
const payload = JSON.parse(body);
await db.webhooks.insertOne({{
idempotencyKey,
verificationId: payload.verificationId,
result: payload.result,
processedAt: new Date(),
}});
// Return 200 immediately
res.status(200).json({{ received: true }});
// Process asynchronously
processWebhookAsync(payload);
}});
Error Handling
try {{
const response = await fetch('{BASE_URL}/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://app.example.com/webhooks/pylon'
}})
}});
if (response.status === 401) {{
console.error('❌ Invalid API key');
}} else if (response.status === 429) {{
console.error('❌ Rate limited');
}} else if (response.status === 400) {{
console.error('❌ Invalid request');
}} else if (!response.ok) {{
console.error('❌ Error:', response.status);
}}
}} catch (error) {{
console.error('❌ Network error:', error);
}}
Testing Locally
Start the local emulator:
pylon-cli
Point requests to localhost:
const response = 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/webhooks/pylon'
}})
}});
TypeScript Types
You can define your own types until the SDK is released:
interface VerifyAgeRequest {{
policy: {{
minAge: number;
}};
callbackUrl: string;
}}
interface VerifyAgeResponse {{
verificationId: string;
status: string;
walletUrl: string;
}}
interface WebhookPayload {{
verificationId: string;
type: string;
result: 'verified' | 'not_verified';
attributes?: {{
ageOver18?: boolean;
}};
}}
Roadmap
- Q1 2026: Official JavaScript/TypeScript SDK with full type safety
Questions? See Troubleshooting or API Reference
Python SDK
Status: 🔄 Planned. Not yet available.
Official Python SDK for PYLON is under development. Use direct HTTP integration until released.
Current Integration (Direct HTTP)
Until the SDK is available, use the requests library:
pip install requests
import os
import requests
def verify_age():
api_key = os.getenv("PYLON_API_KEY")
response = requests.post(
"{BASE_URL}/v1/verify/age",
json={{
"policy": {{"minAge": 18}},
"callbackUrl": "https://app.example.com/webhooks/pylon"
}},
headers={{
"Content-Type": "application/json",
"Authorization": f"Bearer {{api_key}}"
}}
)
data = response.json()
print(f"Verification ID: {{data['verificationId']}}")
print(f"Wallet URL: {{data['walletUrl']}}")
# Redirect user to data['walletUrl']
verify_age()
Handle Webhooks (Flask)
import os
import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
def validate_pylon_webhook(signature, body, secret):
\"\"\"Validate X-Pylon-Signature header\"\"\"
parts = signature.split(',')
if len(parts) != 2:
return False
t = parts[0].replace('t=', '')
v1 = parts[1].replace('v1=', '')
signed_message = f"{{t}}.{{body}}"
computed = hmac.new(
secret.encode(),
signed_message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(v1, computed)
@app.route("/webhooks/pylon", methods=["POST"])
def pylon_webhook():
signature = request.headers.get("X-Pylon-Signature")
body = request.get_data().decode()
secret = os.getenv("PYLON_WEBHOOK_SECRET")
if not validate_pylon_webhook(signature, body, secret):
return {{"error": "Invalid signature"}}, 401
data = request.json
if data["result"] == "verified":
print("✅ Verified!")
return {{"received": True}}, 200
return {{"received": True}}, 200
if __name__ == "__main__":
app.run(debug=True, port=3000)
Handle Webhooks (FastAPI)
import os
import hmac
import hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
def validate_pylon_webhook(signature: str, body: str, secret: str) -> bool:
parts = signature.split(',')
if len(parts) != 2:
return False
t = parts[0].replace('t=', '')
v1 = parts[1].replace('v1=', '')
signed_message = f"{{t}}.{{body}}"
computed = hmac.new(
secret.encode(),
signed_message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(v1, computed)
@app.post("/webhooks/pylon")
async def pylon_webhook(request: Request):
signature = request.headers.get("X-Pylon-Signature")
body = (await request.body()).decode()
secret = os.getenv("PYLON_WEBHOOK_SECRET")
if not validate_pylon_webhook(signature, body, secret):
raise HTTPException(status_code=401, detail="Invalid signature")
data = await request.json()
if data["result"] == "verified":
print("✅ Verified!")
return {{"received": True}}
return {{"received": True}}
Idempotency Handling
import os
from flask import Flask, request
from datetime import datetime
app = Flask(__name__)
processed_webhooks = {{}} # Use database in production
@app.route("/webhooks/pylon", methods=["POST"])
def pylon_webhook():
idempotency_key = request.headers.get("X-Pylon-Idempotency-Key")
# Check if already processed
if idempotency_key in processed_webhooks:
return {{"status": "already_processed"}}, 200
# Validate signature
signature = request.headers.get("X-Pylon-Signature")
body = request.get_data().decode()
secret = os.getenv("PYLON_WEBHOOK_SECRET")
if not validate_pylon_webhook(signature, body, secret):
return {{"error": "Invalid signature"}}, 401
# Store idempotency key
data = request.json
processed_webhooks[idempotency_key] = {{
"verification_id": data["verificationId"],
"result": data["result"],
"processed_at": datetime.utcnow().isoformat(),
}}
# Return 200 immediately
return {{"received": True}}, 200
# Process asynchronously (use Celery, RQ, etc.)
Error Handling
import requests
import os
try:
api_key = os.getenv("PYLON_API_KEY")
response = requests.post(
"{BASE_URL}/v1/verify/age",
json={{
"policy": {{"minAge": 18}},
"callbackUrl": "https://app.example.com/webhooks/pylon"
}},
headers={{"Authorization": f"Bearer {{api_key}}"}}
)
if response.status_code == 401:
print("❌ Invalid API key")
elif response.status_code == 429:
print("❌ Rate limited")
elif response.status_code == 400:
print(f"❌ Invalid request: {{response.json()}}")
elif response.ok:
print(f"✅ Success: {{response.json()}}")
else:
print(f"❌ Error: {{response.status_code}}")
except requests.exceptions.RequestException as e:
print(f"❌ Network error: {{e}}")
Testing Locally
Start the local emulator:
pylon-cli
Point requests to localhost:
import requests
response = requests.post(
"http://localhost:7777/v1/verify/age",
json={{
"policy": {{"minAge": 18}},
"callbackUrl": "http://localhost:3000/webhooks/pylon"
}}
)
print(response.json())
Roadmap
- Q1 2026: Official Python SDK with type hints and async support
Questions? See Troubleshooting or API Reference
Go SDK
Status: 🔄 Planned. Not yet available.
Official Go SDK for PYLON is under development. Use direct HTTP integration until released.
Current Integration (Direct HTTP)
Until the SDK is available, use Go's standard HTTP client:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
type VerifyAgeRequest struct {{
Policy AgePolicy `json:"policy"`
CallbackURL string `json:"callbackUrl"`
}}
type AgePolicy struct {{
MinAge int `json:"minAge"`
}}
type VerifyAgeResponse struct {{
VerificationID string `json:"verificationId"`
Status string `json:"status"`
WalletURL string `json:"walletUrl"`
}}
func main() {{
req := VerifyAgeRequest{{
Policy: AgePolicy{{MinAge: 18}},
CallbackURL: "https://app.example.com/webhooks/pylon",
}}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", "{BASE_URL}/v1/verify/age", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("PYLON_API_KEY"))
client := &http.Client{{}}
resp, err := client.Do(httpReq)
if err != nil {{
panic(err)
}}
defer resp.Body.Close()
var result VerifyAgeResponse
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Verification ID: %s\\n", result.VerificationID)
fmt.Printf("Wallet URL: %s\\n", result.WalletURL)
// Redirect user to result.WalletURL
}}
Handle Webhooks
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strings"
)
type WebhookResult struct {{
VerificationID string `json:"verificationId"`
Type string `json:"type"`
Result string `json:"result"`
}}
func validateSignature(signature, body, secret string) bool {{
parts := strings.Split(signature, ",")
if len(parts) != 2 {{
return false
}}
t := strings.TrimPrefix(parts[0], "t=")
v1 := strings.TrimPrefix(parts[1], "v1=")
signedMessage := t + "." + body
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedMessage))
computed := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(v1), []byte(computed))
}}
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(signature, string(body), secret) {{
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid signature"))
return
}}
var result WebhookResult
json.Unmarshal(body, &result)
if result.Result == "verified" {{
// Grant access
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{{"received":true}}`))
}} else {{
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{{"received":true}}`))
}}
}}
func main() {{
http.HandleFunc("/webhooks/pylon", webhookHandler)
http.ListenAndServe(":3000", nil)
}}
Idempotency Handling
func webhookHandler(w http.ResponseWriter, r *http.Request) {{
idempotencyKey := r.Header.Get("X-Pylon-Idempotency-Key")
// Check if already processed
if alreadyProcessed(idempotencyKey) {{
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{{"status":"already_processed"}}`))
return
}}
// Validate signature
signature := r.Header.Get("X-Pylon-Signature")
body, _ := io.ReadAll(r.Body)
secret := os.Getenv("PYLON_WEBHOOK_SECRET")
if !validateSignature(signature, string(body), secret) {{
w.WriteHeader(http.StatusUnauthorized)
return
}}
// Store idempotency key
storeIdempotencyKey(idempotencyKey)
// Process webhook
var result WebhookResult
json.Unmarshal(body, &result)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{{"received":true}}`))
// Process asynchronously
go processWebhook(result)
}}
Error Handling
resp, err := client.Do(httpReq)
if err != nil {{
panic(err)
}}
switch resp.StatusCode {{
case 401:
fmt.Println("❌ Invalid API key")
case 429:
fmt.Println("❌ Rate limited")
case 400:
fmt.Println("❌ Invalid request")
default:
fmt.Printf("❌ Error: %d\\n", resp.StatusCode)
}}
Testing Locally
Start the local emulator:
pylon-cli
Point requests to localhost:
httpReq, _ := http.NewRequest("POST", "http://localhost:7777/v1/verify/age", bytes.NewBuffer(body))
Roadmap
- Q1 2026: Official Go SDK release with type-safe client
Questions? See Troubleshooting or API Reference
Java SDK
Status: 🔄 Planned. Not yet available.
Official Java SDK for PYLON is under development. Use direct HTTP integration until released.
Current Integration (Direct HTTP)
Until the SDK is available, use Java's standard HTTP client (Java 11+):
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
public class PylonExample {
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("PYLON_API_KEY");
// Build request
Map<String, Object> requestBody = Map.of(
"policy", Map.of("minAge", 18),
"callbackUrl", "https://app.example.com/webhooks/pylon"
);
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(requestBody);
HttpClient client = HttpClient.newHttpClient();
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 = client.send(request,
HttpResponse.BodyHandlers.ofString());
Map<String, Object> result = mapper.readValue(response.body(), Map.class);
System.out.println("Verification ID: " + result.get("verificationId"));
System.out.println("Wallet URL: " + result.get("walletUrl"));
// Redirect user to wallet URL
}
}
Handle Webhooks (Spring Boot)
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
@RestController
public class WebhookController {
private boolean validateSignature(String signature, String body, String secret) {
try {
String[] parts = signature.split(",");
if (parts.length != 2) return false;
String t = parts.replace("t=", "");
String v1 = parts.replace("v1=", "");
String signedMessage = t + "." + body;
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKey);
byte[] hash = mac.doFinal(signedMessage.getBytes(StandardCharsets.UTF_8));
StringBuilder computed = new StringBuilder();
for (byte b : hash) {
computed.append(String.format("%02x", b));
}
return v1.equals(computed.toString());
} catch (Exception e) {
return false;
}
}
@PostMapping("/webhooks/pylon")
public ResponseEntity<?> handlePylonWebhook(
@RequestHeader("X-Pylon-Signature") String signature,
@RequestBody String body
) {
String secret = System.getenv("PYLON_WEBHOOK_SECRET");
if (!validateSignature(signature, body, secret)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (body.contains("\"result\":\"verified\"")) {
System.out.println("✅ Verified!");
return ResponseEntity.ok(Map.of("received", true));
}
return ResponseEntity.ok(Map.of("received", true));
}
}
Idempotency Handling
import org.springframework.data.repository.CrudRepository;
import java.time.Instant;
@RestController
public class WebhookController {
@Autowired
private WebhookRepository webhookRepo;
@PostMapping("/webhooks/pylon")
public ResponseEntity<?> handlePylonWebhook(
@RequestHeader("X-Pylon-Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Pylon-Signature") String signature,
@RequestBody String body
) {
// Check if already processed
if (webhookRepo.existsById(idempotencyKey)) {
return ResponseEntity.ok(Map.of("status", "already_processed"));
}
// Validate signature
String secret = System.getenv("PYLON_WEBHOOK_SECRET");
if (!validateSignature(signature, body, secret)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Store idempotency key
WebhookRecord record = new WebhookRecord();
record.setIdempotencyKey(idempotencyKey);
record.setProcessedAt(Instant.now());
webhookRepo.save(record);
// Return 200 immediately
ResponseEntity.ok(Map.of("received", true));
// Process asynchronously
processWebhookAsync(body);
return ResponseEntity.ok(Map.of("received", true));
}
@Async
private void processWebhookAsync(String body) {
// Do background work here
}
}
interface WebhookRepository extends CrudRepository<WebhookRecord, String> {}
Error Handling
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
switch (response.statusCode()) {
case 401:
System.err.println("❌ Invalid API key");
break;
case 429:
System.err.println("❌ Rate limited");
break;
case 400:
System.err.println("❌ Invalid request");
break;
default:
System.err.println("❌ Error: " + response.statusCode());
}
Testing Locally
Start the local emulator:
pylon-cli
Point requests to localhost:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:7777/v1/verify/age"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
Roadmap
- Q1 2026: Official Java SDK release with async-first API using Project Reactor
Questions? See Troubleshooting or API Reference
Example: Age Verification Integration
Complete working example of age verification in your app using direct HTTP integration.
The Flow
1. User clicks "Verify age"
2. Your app calls POST /v1/verify/age
3. PYLON returns walletUrl
4. User scans QR code with EUDI wallet
5. Wallet asks: "Share age > 18?"
6. User taps Accept/Deny
7. Wallet sends proof to PYLON
8. PYLON validates and fires webhook
9. Your app gets result and grants/denies access
Node.js + Express
import express from 'express';
import crypto from 'crypto';
import fetch from 'node-fetch';
import QRCode from 'qrcode';
const app = express();
app.use(express.json());
// 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();
// Generate QR code
const qr = await QRCode.toDataURL(data.walletUrl);
res.json({
verificationId: data.verificationId,
qrCode: qr,
walletUrl: data.walletUrl
});
});
// 2. Handle webhook
app.post('/api/webhooks/pylon', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-pylon-signature'];
const body = req.body.toString();
// Validate signature
function validateSignature(sig, body, secret) {
const [t, v1] = sig.split(',');
const tValue = t.replace('t=', '');
const signedMessage = `${tValue}.${body}`;
const computed = crypto.createHmac('sha256', secret).update(signedMessage).digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1.replace('v1=', '')), Buffer.from(computed));
}
if (!validateSignature(signature, body, process.env.PYLON_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(body);
const { verificationId, result, attributes } = payload;
if (result === 'verified' && attributes.ageOver18) {
console.log(`✅ User verified as age > 18`);
} else {
console.log(`❌ Verification failed or rejected`);
}
res.status(200).json({ received: true });
});
app.listen(3000);
Python + Flask
import os
import requests
import hmac
import hashlib
from flask import Flask, request, jsonify
import qrcode
from io import BytesIO
import base64
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.QRCode(version=1, box_size=10, border=5)
qr.add_data(data['walletUrl'])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf)
qr_base64 = base64.b64encode(buf.getvalue()).decode()
return jsonify({
'verificationId': data['verificationId'],
'qrCode': f'data:image/png;base64,{qr_base64}',
'walletUrl': data['walletUrl']
})
def validate_signature(signature, body, secret):
parts = signature.split(',')
t = parts.replace('t=', '')
v1 = parts.replace('v1=', '')
signed_message = f"{t}.{body}"
computed = hmac.new(secret.encode(), signed_message.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, computed)
@app.route('/webhook/pylon', methods=['POST'])
def pylon_webhook():
signature = request.headers.get('X-Pylon-Signature')
body = request.get_data().decode()
secret = os.getenv('PYLON_WEBHOOK_SECRET')
if not validate_signature(signature, body, secret):
return {'error': 'Invalid signature'}, 401
data = request.json
if data['result'] == 'verified' and data.get('attributes', {}).get('ageOver18'):
print(f"✅ User {data['verificationId']} verified as age > 18")
return {'received': True}, 200
return {'received': True}, 200
if __name__ == '__main__':
app.run(debug=True, port=5000)
Testing Locally
# Start local emulator
pylon-cli
# In another terminal, start your app
node app.js # (or python app.py)
# Make request
curl -X POST http://localhost:3000/api/verify-age
# Emulator auto-completes immediately
Error Handling
try {
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.status === 401) {
console.error('Invalid API key');
} else if (response.status === 429) {
console.error('Rate limited');
} else if (response.status === 400) {
console.error('Invalid callback URL (must be HTTPS)');
}
} catch (error) {
console.error('Network error:', error);
}
Next: API Reference | Troubleshooting
Example: KYC Reuse
Status: 🔄 Planned. Not yet available.
KYC verification via EUDI wallets is under development. This example shows the planned integration pattern.
Current Status
The /v1/verify/kyc endpoint is not yet implemented. Only age verification (/v1/verify/age) is currently available.
Planned Flow
1. User clicks "Complete KYC"
2. Your app calls POST /v1/verify/kyc (not yet available)
3. PYLON returns walletUrl
4. User scans QR code
5. Wallet shows: "Share name, address?"
6. User taps Accept
7. Wallet sends selective proof
8. PYLON validates and fires webhook
9. Your app gets attributes without storing raw data
Why This Matters (When Available)
✅ Privacy: User controls what's shared (selective disclosure)
✅ Compliance: Never store raw PII (just verified attributes)
✅ Trust: Attributes come from government wallet
✅ GDPR: Easier data retention/deletion
Roadmap
- Q1 2026: KYC verification endpoint
- Q2 2026: Additional attribute support
Current: Use Age Verification for now
Reference: API Reference | Troubleshooting
Example: OIDC Login
Status: 🔄 Planned. Not yet available.
"Sign in with EUDI" using OpenID Connect is under development. This example shows the planned integration pattern.
Current Status
PYLON does not currently provide OAuth/OIDC login functionality. The following endpoints are not yet implemented:
- ❌
/.well-known/openid-configuration - ❌
/oauth/authorize - ❌
/oauth/token - ❌
/oauth/userinfo
Planned Flow
1. User clicks "Sign in with EUDI"
2. Your app redirects to PYLON OAuth
3. User scans QR code with wallet
4. Wallet asks: "Sign in to Your App?"
5. User taps Accept
6. PYLON redirects back with auth code
7. Your app exchanges code for ID token
8. User logged in
Why OIDC? (When Available)
✅ Standard: Uses industry-standard OpenID Connect
✅ Familiar: Works like "Sign in with Google"
✅ Secure: OAuth 2.0 with PKCE
✅ Private: No password sharing
Roadmap
- Q2 2026: OAuth/OIDC provider functionality
- Q3 2026: "Sign in with EUDI" button SDK
Current: Use Age Verification for now
Reference: API Reference | Troubleshooting