Skip to content

Webhook Integration Guide

Table of Contents

  1. Overview
  2. Getting Started
  3. Webhook Events
  4. Security & Verification
  5. Implementation Examples
  6. Best Practices
  7. 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

  1. Log in to your enterprise account
  2. Navigate to SettingsWebhooks
  3. Click Create Webhook
  4. Enter your endpoint URL
  5. Select events to subscribe to
  6. 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:

  1. Webhooks actually come from Pocketbook
  2. Payload hasn't been tampered with
  3. You're not vulnerable to replay attacks

Signature Format

X-Webhook-Signature: t=1640995200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t = Unix timestamp
  • v1 = 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/pocketbook

Troubleshooting

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?

Released under the MIT License.