Skip to main content

Webhook Verification

Verify webhook signatures to ensure requests are from Skippy.

How It Works

  1. Skippy signs each webhook payload with your webhook secret
  2. Signature is sent in the X-Skippy-Signature header
  3. You verify the signature before processing

Get Your Webhook Secret

  1. Go to Project SettingsWebhooks
  2. Create or edit a webhook
  3. 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

HeaderDescription
X-Skippy-SignatureHMAC-SHA256 signature (sha256=...)
X-Skippy-TimestampUnix timestamp when sent
Content-Typeapplication/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

IssueSolution
Invalid signatureCheck webhook secret is correct
Timestamp rejectedEnsure server clock is synced (NTP)
Payload mismatchUse raw body, not parsed JSON
Missing headersCheck reverse proxy isn't stripping headers

Next Steps