Appearance
Table of Contents
- Overview
- Getting Started
- Webhook Events
- Security & Verification
- Implementation Examples
- Best Practices
- Troubleshooting
Overview
Webhooks allow your application to receive real-time notifications when events occur in your Pocketbook account. Instead of polling the API, webhooks push data to your server immediately when events happen.
Benefits
✅ Real-time: Instant notifications when events occur ✅ Efficient: No need for constant API polling ✅ Reliable: Automatic retries with exponential backoff ✅ Secure: Cryptographic signature verification ✅ Flexible: Subscribe to only the events you need
How Webhooks Work
┌──────────────┐ Event Occurs ┌──────────────┐
│ Pocketbook │ ───────────────────────> │ │
│ API │ │ Your Server │
└──────────────┘ └──────────────┘
│
┌───────────────────────────────┘
│
▼
┌──────────────┐
│ Process │
│ Event │
└──────────────┘
│
▼
┌──────────────┐
│ Return │
│ 200 OK │
└──────────────┘Getting Started
Step 1: Create a Webhook Endpoint
Your webhook endpoint must:
- Accept POST requests
- Return a 200-299 status code quickly (< 30s)
- Use HTTPS (required for production)
- Verify webhook signatures
javascript
// Express.js example
const express = require('express');
const app = express();
// Important: Use express.raw() to preserve raw body for signature verification
app.post('/webhooks/pocketbook',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
// Get raw body
const rawBody = req.body.toString('utf8');
const signature = req.headers['x-webhook-signature'];
// Verify signature (see Security section)
if (!verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse event
const event = JSON.parse(rawBody);
// Process event asynchronously
processEvent(event).catch(console.error);
// Return 200 immediately
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Bad request' });
}
}
);
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});Step 2: Register Your Webhook
Via API
javascript
const response = await fetch('https://api.pocketbook.studio/api/enterprise/webhooks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwtToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://your-domain.com/webhooks/pocketbook',
events: [
'certificate.created',
'certificate.minted',
'voucher.created',
'voucher.minted',
'bulk_job.completed'
],
active: true
})
});
const data = await response.json();
console.log('Webhook ID:', data.data.webhookId);
console.log('Webhook Secret:', data.data.secret); // Store securely!Via Dashboard
- Log in to your enterprise account
- Navigate to Settings → Webhooks
- Click Create Webhook
- Enter your endpoint URL
- Select events to subscribe to
- Save and copy the webhook secret
Step 3: Store Webhook Secret
bash
# .env file
POCKETBOOK_WEBHOOK_SECRET=whsec_1234567890abcdef⚠️ Important: Never commit webhook secrets to version control!
Webhook Events
Certificate Events
certificate.created
Fired when a new certificate is created.
json
{
"id": "evt_1234567890",
"type": "certificate.created",
"created": 1640995200,
"data": {
"certificateId": "507f1f77bcf86cd799439011",
"name": "Achievement Certificate",
"description": "Awarded for completion",
"imageUrl": "https://ipfs.io/ipfs/QmHash...",
"owner": "0x1234567890123456789012345678901234567890",
"metadata": {
"category": "education",
"attributes": [
{ "trait_type": "Course", "value": "Web Development 101" }
]
},
"createdAt": "2025-01-15T10:30:00Z"
}
}certificate.minted
Fired when a certificate is minted on the blockchain.
json
{
"id": "evt_1234567891",
"type": "certificate.minted",
"created": 1640995300,
"data": {
"certificateId": "507f1f77bcf86cd799439011",
"tokenId": "123456",
"transactionHash": "0xabcdef...",
"owner": "0x1234567890123456789012345678901234567890",
"mintedAt": "2025-01-15T10:35:00Z"
}
}certificate.transferred
Fired when a certificate is transferred to a new owner.
json
{
"id": "evt_1234567892",
"type": "certificate.transferred",
"created": 1640995400,
"data": {
"certificateId": "507f1f77bcf86cd799439011",
"tokenId": "123456",
"from": "0x1234567890123456789012345678901234567890",
"to": "0x0987654321098765432109876543210987654321",
"transactionHash": "0xfedcba...",
"transferredAt": "2025-01-15T10:40:00Z"
}
}certificate.burned
Fired when a certificate is burned (destroyed).
json
{
"id": "evt_1234567893",
"type": "certificate.burned",
"created": 1640995500,
"data": {
"certificateId": "507f1f77bcf86cd799439011",
"tokenId": "123456",
"transactionHash": "0xburned...",
"burnedAt": "2025-01-15T10:45:00Z"
}
}Voucher Events
voucher.created
Fired when a new voucher is created.
json
{
"id": "evt_1234567894",
"type": "voucher.created",
"created": 1640995600,
"data": {
"voucherId": "507f1f77bcf86cd799439012",
"certificateId": "507f1f77bcf86cd799439011",
"referenceNumber": "PB-ABC123",
"expiry": "1672531200",
"metadata": {
"recipientEmail": "user@example.com",
"recipientName": "John Doe"
},
"claimUrl": "https://pocketbook.studio/claim/507f1f77bcf86cd799439012",
"createdAt": "2025-01-15T10:50:00Z"
}
}voucher.minted
Fired when a voucher is redeemed/minted.
json
{
"id": "evt_1234567895",
"type": "voucher.minted",
"created": 1640995700,
"data": {
"voucherId": "507f1f77bcf86cd799439012",
"certificateId": "507f1f77bcf86cd799439011",
"referenceNumber": "PB-ABC123",
"mintedBy": "0x1234567890123456789012345678901234567890",
"transactionHash": "0xminted...",
"mintedAt": "2025-01-15T10:55:00Z"
}
}voucher.expired
Fired when a voucher expires without being minted.
json
{
"id": "evt_1234567896",
"type": "voucher.expired",
"created": 1640995800,
"data": {
"voucherId": "507f1f77bcf86cd799439012",
"certificateId": "507f1f77bcf86cd799439011",
"referenceNumber": "PB-ABC123",
"expiredAt": "2025-01-15T11:00:00Z"
}
}Bulk Job Events
bulk_job.started
Fired when a bulk mint job starts processing.
json
{
"id": "evt_1234567897",
"type": "bulk_job.started",
"created": 1640995900,
"data": {
"jobId": "507f1f77bcf86cd799439013",
"totalCount": 100,
"startedAt": "2025-01-15T11:05:00Z"
}
}bulk_job.completed
Fired when a bulk mint job completes successfully.
json
{
"id": "evt_1234567898",
"type": "bulk_job.completed",
"created": 1641000000,
"data": {
"jobId": "507f1f77bcf86cd799439013",
"totalCount": 100,
"successCount": 98,
"failedCount": 2,
"voucherLinks": {
"voucher_all": ["https://pocketbook.studio/claim/..."],
"voucher_success": ["https://pocketbook.studio/claim/..."]
},
"completedAt": "2025-01-15T11:25:00Z"
}
}bulk_job.failed
Fired when a bulk mint job fails.
json
{
"id": "evt_1234567899",
"type": "bulk_job.failed",
"created": 1641000100,
"data": {
"jobId": "507f1f77bcf86cd799439013",
"error": "Processing error occurred",
"failedAt": "2025-01-15T11:10:00Z"
}
}Security & Verification
Why Verify Signatures?
Signature verification ensures that:
- Webhooks actually come from Pocketbook
- Payload hasn't been tampered with
- You're not vulnerable to replay attacks
Signature Format
X-Webhook-Signature: t=1640995200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt= Unix timestampv1= HMAC SHA256 signature
Verification Implementation
javascript
const crypto = require('crypto');
function verifyWebhookSignature(payload, signatureHeader, secret) {
// Parse signature header
const signature = signatureHeader
.split(',')
.reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key.trim()] = value.trim();
return acc;
}, {});
const timestamp = signature.t;
const hash = signature.v1;
// Verify timestamp (prevent replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
const timeDifference = Math.abs(currentTime - parseInt(timestamp));
if (timeDifference > 300) { // 5 minutes tolerance
throw new Error('Webhook timestamp too old or too far in the future');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedHash = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures (timing-safe comparison)
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))) {
throw new Error('Invalid webhook signature');
}
return true;
}
// Usage
try {
verifyWebhookSignature(
req.body.toString('utf8'),
req.headers['x-webhook-signature'],
process.env.POCKETBOOK_WEBHOOK_SECRET
);
console.log('Signature verified ✓');
} catch (error) {
console.error('Signature verification failed:', error.message);
return res.status(401).json({ error: 'Unauthorized' });
}Complete Express.js Example
javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
// Webhook secret from environment
const WEBHOOK_SECRET = process.env.POCKETBOOK_WEBHOOK_SECRET;
// Verification function
function verifyWebhookSignature(payload, signatureHeader, secret) {
const signature = signatureHeader
.split(',')
.reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key.trim()] = value.trim();
return acc;
}, {});
const timestamp = signature.t;
const hash = signature.v1;
// Check timestamp
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old');
}
// Verify signature
const signedPayload = `${timestamp}.${payload}`;
const expectedHash = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))) {
throw new Error('Invalid signature');
}
return true;
}
// Webhook endpoint
app.post('/webhooks/pocketbook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.headers['x-webhook-signature'];
try {
// Verify signature
verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET);
// Parse event
const event = JSON.parse(rawBody);
// Process event (async)
processWebhookEvent(event).catch(console.error);
// Respond immediately
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: error.message });
}
}
);
async function processWebhookEvent(event) {
console.log(`Processing event: ${event.type}`);
switch (event.type) {
case 'certificate.created':
await handleCertificateCreated(event.data);
break;
case 'certificate.minted':
await handleCertificateMinted(event.data);
break;
case 'voucher.minted':
await handleVoucherMinted(event.data);
break;
case 'bulk_job.completed':
await handleBulkJobCompleted(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
app.listen(3000);Implementation Examples
Express.js + MongoDB
javascript
const express = require('express');
const mongoose = require('mongoose');
// Certificate model
const CertificateSchema = new mongoose.Schema({
pocketbookId: String,
tokenId: String,
studentEmail: String,
status: String,
mintedAt: Date,
transactionHash: String
});
const Certificate = mongoose.model('Certificate', CertificateSchema);
// Event handlers
async function handleCertificateCreated(data) {
await Certificate.create({
pocketbookId: data.certificateId,
studentEmail: data.metadata?.recipientEmail,
status: 'created',
createdAt: new Date(data.createdAt)
});
console.log(`Certificate created: ${data.certificateId}`);
}
async function handleCertificateMinted(data) {
await Certificate.findOneAndUpdate(
{ pocketbookId: data.certificateId },
{
status: 'minted',
tokenId: data.tokenId,
transactionHash: data.transactionHash,
mintedAt: new Date(data.mintedAt)
}
);
// Send notification email
await sendCertificateMintedEmail(data);
console.log(`Certificate minted: ${data.certificateId}`);
}
async function handleVoucherMinted(data) {
// Update voucher status in your database
await db.vouchers.update(
{ pocketbookId: data.voucherId },
{ status: 'minted', mintedAt: new Date() }
);
// Trigger analytics event
analytics.track('voucher_minted', {
voucherId: data.voucherId,
certificateId: data.certificateId,
user: data.mintedBy
});
}
async function handleBulkJobCompleted(data) {
// Update job status
await db.bulkJobs.update(
{ pocketbookJobId: data.jobId },
{
status: 'completed',
successCount: data.successCount,
failedCount: data.failedCount,
completedAt: new Date()
}
);
// Notify admin
await sendBulkJobCompletionEmail({
jobId: data.jobId,
totalCount: data.totalCount,
successCount: data.successCount,
failedCount: data.failedCount
});
}Next.js API Route
typescript
// pages/api/webhooks/pocketbook.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
import { buffer } from 'micro';
export const config = {
api: {
bodyParser: false,
},
};
function verifySignature(payload: string, signature: string): boolean {
const parts = signature.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key.trim()] = value.trim();
return acc;
}, {} as Record<string, string>);
const signedPayload = `${parts.t}.${payload}`;
const expectedHash = crypto
.createHmac('sha256', process.env.POCKETBOOK_WEBHOOK_SECRET!)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(parts.v1),
Buffer.from(expectedHash)
);
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Get raw body
const buf = await buffer(req);
const rawBody = buf.toString('utf8');
const signature = req.headers['x-webhook-signature'] as string;
// Verify signature
if (!verifySignature(rawBody, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse event
const event = JSON.parse(rawBody);
// Process event
await processEvent(event);
// Return success
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Bad request' });
}
}Python Flask
python
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import json
app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret'
def verify_webhook_signature(payload, signature_header):
# Parse signature
parts = dict(part.split('=') for part in signature_header.split(','))
timestamp = parts['t']
received_hash = parts['v1']
# Check timestamp (5 minute tolerance)
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_hash = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare
if not hmac.compare_digest(received_hash, expected_hash):
raise ValueError('Invalid signature')
return True
@app.route('/webhooks/pocketbook', methods=['POST'])
def webhook():
try:
# Get raw body and signature
payload = request.get_data(as_text=True)
signature = request.headers.get('X-Webhook-Signature')
# Verify signature
verify_webhook_signature(payload, signature)
# Parse event
event = json.loads(payload)
# Process event
process_event(event)
return jsonify({'received': True}), 200
except Exception as e:
print(f"Webhook error: {e}")
return jsonify({'error': str(e)}), 400
def process_event(event):
event_type = event['type']
if event_type == 'certificate.created':
handle_certificate_created(event['data'])
elif event_type == 'certificate.minted':
handle_certificate_minted(event['data'])
elif event_type == 'voucher.minted':
handle_voucher_minted(event['data'])
# ... handle other events
if __name__ == '__main__':
app.run(port=3000)Best Practices
1. Return 200 Quickly
Process webhooks asynchronously to avoid timeouts:
javascript
app.post('/webhooks/pocketbook', async (req, res) => {
// Verify signature
verifySignature(req.body, req.headers['x-webhook-signature']);
// Queue for processing
await queue.add('process-webhook', req.body);
// Return immediately
res.status(200).json({ received: true });
});
// Process in background worker
queue.process('process-webhook', async (job) => {
await processEvent(job.data);
});2. Handle Idempotency
Same event may be delivered multiple times. Use event ID for deduplication:
javascript
const processedEvents = new Set();
async function processEvent(event) {
// Check if already processed
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process event
await handleEvent(event);
// Mark as processed
processedEvents.add(event.id);
// Persist to database
await db.processedEvents.create({ eventId: event.id, processedAt: new Date() });
}3. Monitor Webhook Health
Track webhook delivery success rate:
javascript
const webhookMetrics = {
received: 0,
processed: 0,
failed: 0
};
app.post('/webhooks/pocketbook', async (req, res) => {
webhookMetrics.received++;
try {
await processEvent(req.body);
webhookMetrics.processed++;
res.status(200).json({ received: true });
} catch (error) {
webhookMetrics.failed++;
console.error('Processing failed:', error);
res.status(500).json({ error: 'Processing failed' });
}
});
// Expose metrics
app.get('/metrics/webhooks', (req, res) => {
res.json(webhookMetrics);
});4. Use Webhook Secret Rotation
Implement graceful secret rotation:
javascript
const CURRENT_SECRET = process.env.POCKETBOOK_WEBHOOK_SECRET;
const PREVIOUS_SECRET = process.env.POCKETBOOK_WEBHOOK_SECRET_OLD;
function verifyWithFallback(payload, signature) {
try {
// Try current secret
return verifyWebhookSignature(payload, signature, CURRENT_SECRET);
} catch (error) {
// Fallback to previous secret (during rotation)
if (PREVIOUS_SECRET) {
return verifyWebhookSignature(payload, signature, PREVIOUS_SECRET);
}
throw error;
}
}5. Test Webhooks Locally
Use ngrok or similar tools for local testing:
bash
# Terminal 1: Start your local server
npm start
# Terminal 2: Create ngrok tunnel
ngrok http 3000
# Use the ngrok URL for webhook registration
https://abcd1234.ngrok.io/webhooks/pocketbookTroubleshooting
Issue: Webhooks not being received
Checklist:
- ✅ Webhook is active in dashboard
- ✅ URL is accessible from internet (use ngrok for local dev)
- ✅ HTTPS is used (required for production)
- ✅ Firewall allows incoming connections
- ✅ Correct events are subscribed
Issue: Signature verification failing
Solutions:
- Use raw body (not parsed JSON)
- Check secret matches registration
- Verify timestamp tolerance (5 minutes)
- Ensure signature header is preserved
Issue: Timeouts
Solutions:
- Return 200 quickly (< 5 seconds)
- Process events asynchronously
- Use background jobs/queues
- Avoid long database queries in handler
Issue: Duplicate events
Solutions:
- Implement idempotency using event IDs
- Store processed event IDs in database
- Use database transactions
Testing Webhooks
javascript
// Test webhook signature generation
function generateTestSignature(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const hash = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return `t=${timestamp},v1=${hash}`;
}
// Send test webhook
const testPayload = {
id: 'evt_test_123',
type: 'certificate.created',
created: Math.floor(Date.now() / 1000),
data: { /* test data */ }
};
const signature = generateTestSignature(testPayload, WEBHOOK_SECRET);
await fetch('http://localhost:3000/webhooks/pocketbook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature
},
body: JSON.stringify(testPayload)
});Need Help?
- Documentation: docs.pocketbook.studio
- Email: seth@pocketbook.studio
- Discord: discord.gg/pocketbook
