Webhook Verification
Verify webhook signatures to ensure requests are from Skippy.
How It Works
- Skippy signs each webhook payload with your webhook secret
- Signature is sent in the
X-Skippy-Signatureheader - You verify the signature before processing
Get Your Webhook Secret
- Go to Project Settings → Webhooks
- Create or edit a webhook
- Copy the Webhook Secret (starts with
whsec_)
Store it securely:
export SKIPPY_WEBHOOK_SECRET="whsec_abc123..."
Verification Examples
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(req) {
const signature = req.headers['x-skippy-signature'];
const timestamp = req.headers['x-skippy-timestamp'];
const body = JSON.stringify(req.body);
// Check timestamp (reject if older than 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
// Compute expected signature
const payload = `${timestamp}.${body}`;
const expected = crypto
.createHmac('sha256', process.env.SKIPPY_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`)
);
}
// Express.js example
app.post('/webhooks/skippy', express.json(), (req, res) => {
if (!verifyWebhookSignature(req)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const event = req.body;
switch (event.type) {
case 'credential.issued':
handleCredentialIssued(event.data);
break;
case 'verification.successful':
handleVerificationSuccess(event.data);
break;
default:
console.log('Unhandled event type:', event.type);
}
res.status(200).send('OK');
});
Python
import hmac
import hashlib
import time
import os
from flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(request):
signature = request.headers.get('X-Skippy-Signature', '')
timestamp = request.headers.get('X-Skippy-Timestamp', '')
body = request.get_data(as_text=True)
# Check timestamp (reject if older than 5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
# Compute expected signature
payload = f"{timestamp}.{body}"
expected = hmac.new(
os.environ['SKIPPY_WEBHOOK_SECRET'].encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(signature, f"sha256={expected}")
@app.route('/webhooks/skippy', methods=['POST'])
def handle_webhook():
if not verify_webhook_signature(request):
abort(401, 'Invalid signature')
event = request.get_json()
if event['type'] == 'credential.issued':
handle_credential_issued(event['data'])
elif event['type'] == 'verification.successful':
handle_verification_success(event['data'])
return 'OK', 200
Webhook Payload Structure
{
"id": "evt_abc123",
"type": "credential.issued",
"timestamp": "2026-01-16T10:30:00Z",
"data": {
"offerId": "offer_xyz789",
"credentialId": "cred_def456",
"templateId": "tmpl_ghi012",
"recipientEmail": "jane@example.com"
}
}
Headers
| Header | Description |
|---|---|
X-Skippy-Signature | HMAC-SHA256 signature (sha256=...) |
X-Skippy-Timestamp | Unix timestamp when sent |
Content-Type | application/json |
Retry Logic
If your endpoint returns non-2xx:
- Skippy retries up to 3 times
- Delays: 1 min, 5 min, 30 min
- After 3 failures, webhook is marked failed
Handle idempotency:
const processedEvents = new Set();
function handleWebhook(event) {
// Skip if already processed
if (processedEvents.has(event.id)) {
return { status: 'already_processed' };
}
// Process event
processEvent(event);
// Mark as processed
processedEvents.add(event.id);
return { status: 'processed' };
}
Testing Locally
Use a tunnel for local development:
# Using ngrok
ngrok http 3000
# Your webhook URL becomes:
# https://abc123.ngrok.io/webhooks/skippy
Or use the Skippy CLI to send test events:
skippy webhooks test --event credential.issued --url http://localhost:3000/webhooks/skippy
Troubleshooting
| Issue | Solution |
|---|---|
| Invalid signature | Check webhook secret is correct |
| Timestamp rejected | Ensure server clock is synced (NTP) |
| Payload mismatch | Use raw body, not parsed JSON |
| Missing headers | Check reverse proxy isn't stripping headers |
Next Steps
- Webhook Events — All supported event types
- API Reference — Full endpoint documentation