Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What is 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 request
  • walletUrl: 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?

FeaturePYLON
Time to integrate10 minutes
Learning curveMinimal (REST API, not cryptography)
Data sovereigntyEU-only, no US sub-processors
Lock-inNone (standards-native, export guaranteed)
Developer DXSDKs, 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


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

EnvironmentURL
Sandboxhttps://sandbox.api.pylonid.eu
Productionhttps://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:

  1. Display QR code (or link) to walletUrl
  2. User scans with EUDI wallet app
  3. Wallet shows: "Verify age >= 18?" with Accept/Deny buttons
  4. User taps Accept/Deny
  5. 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

ErrorMeaningFix
401 UnauthorizedInvalid API keyCheck $PYLON_API_KEY export
400 Bad RequestInvalid policyCheck JSON syntax and minAge (0-150)
429 Too Many RequestsRate limitedWait 60s and retry; check dashboard for usage

Next Steps


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:

  1. Your app requests a presentation: "Prove your age >= 18"
  2. Wallet responds with a presentation (selective disclosure proof)
  3. 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


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:

  1. Redirect user to walletUrl
  2. User scans with EUDI wallet and presents credential
  3. 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_456
  • Content-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-Key header 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:

  1. Create a test credential presentation
  2. Validate the age logic
  3. Fire a webhook to your callback URL
  4. 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

FeatureBehavior
Age ValidationChecks minAge against mock credential
Webhook FiringSends POST to callback URL immediately
StateIn-memory, clears on restart
RetryNone (fires once immediately)
SignatureNo signature validation

Production Differences

AspectEmulatorProduction
URLhttp://localhost:7777https://pylonid.eu
WalletFake HTML UIReal German EUDI wallet
SignaturesMockedReal OID4VP signature verification
RetryNoneExponential backoff retries
StorageIn-memoryPostgreSQL persistent
AuthNoneAPI 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

  1. Integrate official SDKs (Go, JS, Python, Rust, Java)
  2. Test error handling by sending invalid payloads
  3. Test webhooks for proper app behavior
  4. 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:

  1. Go Settings > Webhooks
  2. Add endpoint: https://app.example.com/api/webhooks/pylon
  3. Copy Webhook Secret (save securely)
  4. 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

HeaderPurposeExample
X-PYLON-SignatureHMAC-SHA256 signature for verificationt=1678886400,v1=abcd...
X-Pylon-Idempotency-KeyUnique key for retry deduplicationidem_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 wallet
  • user_cancelled — User closed wallet without responding
  • credential_invalid — Wallet sent invalid/expired credential
  • policy_mismatch — Credential doesn't meet policy (e.g., user is 17, policy requires 18)
  • timeout — User didn't respond within 15 minutes
  • error — 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 webhook
  • v1 = 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:

AttemptDelayTotal Time
10s0s
230s30s
35m5m 30s
41h1h 5m 30s
524h25h 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:

  1. Is callback URL HTTPS? (HTTP rejected)
  2. Is it publicly accessible? curl https://app.example.com/webhook
  3. Does it return HTTP 200 within 10 seconds?
  4. 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:

  1. Check URL format: Should be https://pylonid.eu/...
  2. Test on mobile: Desktop browsers can't open wallet
  3. Wallet installed? Ask user to install EUDI wallet first
  4. QR code: If URL doesn't work, display QR code instead
  5. Deep linking: Some wallets require specific link format

"User sees 'Wallet not found'"

Cause: No compatible EUDI wallet installed

Solutions:

  1. Redirect user to wallet app store
  2. Support multiple wallets (Austria, Germany, Italy)
  3. Provide fallback (alternative verification method)

"Verification times out (user doesn't respond)"

Timeout: 15 minutes

Solutions:

  1. Extend timeout on your UI (show timer, refresh link)
  2. Allow user to start over (new verification request)
  3. 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:

  1. Copy API key from dashboard again
  2. Check it's the correct environment (sandbox vs production)
  3. Ensure no whitespace/newlines in key
  4. Regenerate key if it's >90 days old

"403 Forbidden"

Cause: API key lacks permission

Solutions:

  1. Check key scope in dashboard (should have full access)
  2. Rotate key if permissions were recently changed
  3. 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:

  1. Check usage: Dashboard > Usage
  2. Wait 60 seconds: PYLON resets limits hourly
  3. Upgrade tier: Paid plans have higher limits
  4. 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:

  1. Is callback URL HTTPS? (HTTP rejected)    bash    curl https://app.example.com/webhook    # Should return 200, not error   

  2. Is it publicly accessible?    bash    # Test from another machine    curl https://app.example.com/webhook   

  3. Does it return HTTP 200?    bash    curl -i https://app.example.com/webhook    # Look for "200 OK" in headers   

  4. 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= or v1= 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 dev output)
  • 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:

Email Support:

Community Discord:

Status Page:

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:

StandardStatusPurpose
OID4VP 1.0✅ Final (Jun 2025)Verifiable presentations protocol
SD-JWT VC✅ StandardSelective disclosure credentials
ISO 18013-5/7✅ RequiredMobile document format
FAPI 2 Baseline✅ StandardOAuth 2.0 security

Government Wallets

CountryWallet NameStatusTesting StatusNotes
AustriaeID Austria✅ Live✅ TestedIntegrating EUDI features
ItalyIO App✅ Live✅ TestedIncludes EUDI credentials
GermanyBDr Wallet🔄 Sandbox🔄 TestingSandbox launch expected Nov 2025
GreeceTBD🔄 Dev⏳ Pending2025-2026 rollout
LuxembourgTBD🔄 Dev⏳ Pending2025-2026 rollout
PolandTBD🔄 Dev⏳ Pending2025-2026 rollout

Commercial Wallets

ProviderStatusNotes
Lissi🔄 BetaTesting OID4VP compliance
Verimi🔄 BetaTesting SD-JWT support
walt.id🔄 BetaTesting 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:

  1. Local emulator (pylon-cli): Instant and deterministic
  2. Sandbox with German wallet (Nov 2025+): Real wallet, test backend
  3. 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:


Public Conformance & Contribution


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:

  1. Run new migrations: migrations/20250206_003_webhook_schema_update.sql
  2. Redeploy pylon-server
  3. 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

LanguagePackageStatus
Gogithub.com/pylon-id/sdk-go🔄 Planned
JavaScript/TypeScript@pylon-id/sdk🔄 Planned
Pythonpylon-id🔄 Planned
Rustpylon-sdk🔄 Planned
Javacom.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

  1. Initialize HTTP client with API key
  2. Call POST /v1/verify/age with policy and callback URL
  3. Get walletUrl from response
  4. Redirect user to walletUrl
  5. Receive webhook when verification completes
  6. Validate webhook signature (HMAC-SHA256)
  7. 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

  1. Extract X-Pylon-Signature header
  2. Get raw request body (bytes, before JSON parsing)
  3. Retrieve webhook secret from environment
  4. Compute HMAC-SHA256 signature
  5. Compare using timing-safe comparison

See Webhooks Guide for implementation examples in multiple languages.


Error Handling

Common error codes:

CodeMeaningAction
INVALID_API_KEYAPI key missing or invalidCheck environment variable
INVALID_CALLBACK_URLCallback not HTTPSUse valid HTTPS URL
NETWORK_ERRORNetwork failureRetry with backoff
UNKNOWN_ERRORServer errorContact 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-Key header

Return HTTP 200 to acknowledge receipt.


Support


Next Steps


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