Webhooks

Build integrations that react to changes in your SpeyBooks account in real time.

Webhooks push HTTP callbacks to your server when events occur - an invoice is created, a payment is received, a contact is updated. Instead of polling the API, your application learns about changes the moment they happen.

This guide walks through setup, signature verification, event handling, and production best practices. For the full endpoint reference, see Webhooks API.


Setting up your first endpoint

1. Create a handler

Your webhook handler is an HTTPS endpoint that accepts POST requests and returns 200 OK. Here's a minimal Express handler:

const express = require('express');
const app = express();

// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/speybooks', express.raw({ type: 'application/json' }), (req, res) => {
  const event = req.headers['x-speybooks-event'];
  const payload = JSON.parse(req.body);

  console.log(`Received ${event}:`, payload.data);

  // Respond quickly - process asynchronously if needed
  res.sendStatus(200);
});

app.listen(3000);

2. Register the endpoint

The response includes a signing secret prefixed with whsec_. Store it securely - it is only returned on creation and when rotated.

Store your secret The `whsec_` signing secret is shown once. Treat it like a password - environment variables, not source code.

3. Limits

Each organisation can register up to 5 webhook endpoints. Each endpoint can subscribe to any combination of available events.


Available events

Invoice events

EventFires when
invoice.createdA new invoice is created
invoice.sentAn invoice is marked as sent
invoice.paidAn invoice is fully paid
invoice.overdueAn invoice passes its due date
invoice.cancelledAn invoice is voided

Quote events

EventFires when
quote.createdA new quote is created
quote.acceptedA quote is accepted by the customer
quote.rejectedA quote is rejected by the customer

Contact events

EventFires when
contact.createdA new contact is created
contact.updatedContact details are changed

Transaction events

EventFires when
transaction.createdA new transaction is created
transaction.postedA transaction status changes to posted

Payment events

EventFires when
payment.receivedA payment is received against an invoice
Discovering events programmatically `GET /v1/webhooks` returns an `availableEvents` array alongside your endpoints - useful for building dynamic subscription UIs.

Payload format

Every webhook delivery is an HTTP POST with these headers:

HeaderDescription
Content-Typeapplication/json
X-SpeyBooks-EventEvent type, e.g. invoice.created
X-SpeyBooks-SignatureHMAC-SHA256 hex digest
X-SpeyBooks-TimestampUnix timestamp of the event
X-SpeyBooks-DeliveryUnique delivery ID (UUID)

Amounts are in minor units (pence). 420000 means £4,200.00. See Amount Handling for details.


Verifying signatures

Every delivery is signed with HMAC-SHA256. Always verify before processing - an unverified webhook could be spoofed.

How it works

  1. SpeyBooks concatenates the timestamp and raw request body: ${timestamp}.${body}
  2. Signs that string with your whsec_ secret using HMAC-SHA256
  3. Sends the hex digest in the X-SpeyBooks-Signature header

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret) {
  const signature = headers['x-speybooks-signature'];
  const timestamp = headers['x-speybooks-timestamp'];

  // Reject stale deliveries (replay protection)
  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp));
  if (age > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time comparison
  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(rawBody);
}

Python

import hmac
import hashlib
import time
import json

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> dict:
    signature = headers['X-SpeyBooks-Signature']
    timestamp = headers['X-SpeyBooks-Timestamp']

    # Reject stale deliveries
    age = abs(int(time.time()) - int(timestamp))
    if age > 300:
        raise ValueError('Webhook timestamp too old')

    # Compute expected signature
    signed_payload = f'{timestamp}.{raw_body.decode("utf-8")}'
    expected = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(signature, expected):
        raise ValueError('Invalid webhook signature')

    return json.loads(raw_body)

Ruby

require 'openssl'
require 'json'

def verify_webhook(raw_body, headers, secret)
  signature = headers['X-SpeyBooks-Signature']
  timestamp = headers['X-SpeyBooks-Timestamp']

  # Reject stale deliveries
  age = (Time.now.to_i - timestamp.to_i).abs
  raise 'Webhook timestamp too old' if age > 300

  # Compute expected signature
  signed_payload = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload)

  # Constant-time comparison
  raise 'Invalid webhook signature' unless Rack::Utils.secure_compare(signature, expected)

  JSON.parse(raw_body)
end

Go

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "math"
    "strconv"
    "time"
)

func VerifyWebhook(rawBody []byte, signature, timestamp, secret string) (map[string]interface{}, error) {
    // Reject stale deliveries
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return nil, fmt.Errorf("invalid timestamp: %w", err)
    }
    age := math.Abs(float64(time.Now().Unix() - ts))
    if age > 300 {
        return nil, fmt.Errorf("webhook timestamp too old")
    }

    // Compute expected signature
    signedPayload := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison
    if !hmac.Equal([]byte(signature), []byte(expected)) {
        return nil, fmt.Errorf("invalid webhook signature")
    }

    var payload map[string]interface{}
    if err := json.Unmarshal(rawBody, &payload); err != nil {
        return nil, err
    }
    return payload, nil
}
Why constant-time comparison? Standard string comparison (`===`, `==`) leaks timing information that can be exploited to forge signatures. All examples above use constant-time comparison functions from their respective standard libraries.

Handling events

Respond quickly

SpeyBooks waits up to 30 seconds for a response. If your processing takes longer, accept the webhook immediately and process asynchronously:

app.post('/webhooks/speybooks', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = verifyWebhook(req.body, req.headers, process.env.SPEYBOOKS_WEBHOOK_SECRET);

  // Acknowledge immediately
  res.sendStatus(200);

  // Process in background
  processWebhookEvent(payload).catch(err => {
    console.error('Webhook processing failed:', err);
  });
});

Handle duplicates

Webhooks can be delivered more than once - retries, network hiccups, or edge cases. Use the X-SpeyBooks-Delivery header as an idempotency key:

const processedDeliveries = new Set(); // In production, use Redis or a database

async function processWebhookEvent(payload, deliveryId) {
  if (processedDeliveries.has(deliveryId)) {
    console.log(`Duplicate delivery ${deliveryId}, skipping`);
    return;
  }

  processedDeliveries.add(deliveryId);

  switch (payload.event) {
    case 'invoice.paid':
      await handleInvoicePaid(payload.data);
      break;
    case 'contact.updated':
      await handleContactUpdated(payload.data);
      break;
    default:
      console.log(`Unhandled event: ${payload.event}`);
  }
}

Route by event type

A clean pattern for larger applications:

const handlers = {
  'invoice.created': async (data) => { /* ... */ },
  'invoice.paid': async (data) => { /* ... */ },
  'payment.received': async (data) => { /* ... */ },
  'contact.updated': async (data) => { /* ... */ },
};

async function processWebhookEvent(payload) {
  const handler = handlers[payload.event];
  if (handler) {
    await handler(payload.data);
  }
}

Retry policy

Failed deliveries (non-2xx responses or timeouts) are retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours

After 5 failed attempts, the delivery is marked as failed. The endpoint stays active - future events will still be delivered.


Monitoring deliveries

Check delivery health through the API.

Endpoint stats

Every endpoint includes a stats24h object with delivery counts for the last 24 hours.

Delivery log

Inspect individual deliveries for a specific endpoint.

Each delivery record shows the event type, HTTP response status, latency, delivery status, attempt count, and timestamps. Use this to diagnose failures - a responseStatus of 500 points to your handler, while null suggests a network timeout.


Testing locally

Using a tunnel

Services like ngrok or Cloudflare Tunnels expose your local server to the internet:

# Terminal 1: Start your handler
node webhook-handler.js

# Terminal 2: Create a tunnel
ngrok http 3000
# → Forwarding https://abc123.ngrok.io → http://localhost:3000

Register the tunnel URL as a webhook endpoint:

curl -X POST https://api.speybooks.com/v1/webhooks \
  -H "Authorization: Bearer sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/speybooks",
    "events": ["invoice.created"],
    "description": "Local development"
  }'

Then create an invoice through the SpeyBooks UI or API - your local handler should receive the event within seconds.

Crafting test payloads

For unit testing your verification and handler logic without hitting the API:

const crypto = require('crypto');

function createTestWebhook(event, data, secret) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data });

  const signedPayload = `${timestamp}.${body}`;
  const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

  return {
    body,
    headers: {
      'content-type': 'application/json',
      'x-speybooks-event': event,
      'x-speybooks-signature': signature,
      'x-speybooks-timestamp': timestamp,
      'x-speybooks-delivery': crypto.randomUUID(),
    },
  };
}

// Usage in tests
const { body, headers } = createTestWebhook(
  'invoice.paid',
  { id: 'inv_48', invoiceNumber: 'INV-0048', status: 'paid', total: 420000 },
  'whsec_test_secret_for_development'
);

Security checklist

Before going to production:

  1. Verify every signature. Never process an unverified payload. The verification examples above include timestamp checks and constant-time comparison - use them.

  2. Reject stale timestamps. The 5-minute window in the examples above prevents replay attacks. Adjust if your server clock drifts, but don't remove it.

  3. Use HTTPS. SpeyBooks only delivers to HTTPS URLs. If your endpoint redirects HTTP to HTTPS, the POST body may be lost.

  4. Store secrets in environment variables. Never commit whsec_ secrets to source control.

  5. Rotate secrets periodically. Use POST /v1/webhooks/:id/rotate to generate a new secret. The old secret is invalidated immediately - update your handler before or immediately after rotating.

  6. Handle unknown events gracefully. If SpeyBooks adds new event types, your handler should log and ignore them rather than failing.

  7. Deduplicate by delivery ID. The X-SpeyBooks-Delivery header is unique per delivery attempt. Use it to prevent processing the same event twice.


Common patterns

Syncing invoices to an external system

const handlers = {
  'invoice.created': async (data) => {
    await externalCRM.createInvoice({
      externalRef: data.id,
      number: data.invoiceNumber,
      amount: data.total / 100,  // Convert pence to pounds
      contactRef: data.contactId,
    });
  },

  'invoice.paid': async (data) => {
    await externalCRM.markPaid(data.id);
    await slack.notify(`Invoice ${data.invoiceNumber} paid - £${(data.total / 100).toFixed(2)}`);
  },
};

Sending Slack notifications on payment

'payment.received': async (data) => {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `:moneybag: Payment received on ${data.invoiceNumber}`,
    }),
  });
}

Troubleshooting

Webhooks aren't arriving Check that your endpoint is active (isActive: true in the list response) and subscribed to the correct events. Verify your URL is publicly accessible over HTTPS.

Signature verification failing Ensure you're using the raw request body for verification, not a parsed-and-re-serialised version. Express middleware like express.json() will parse the body - use express.raw({ type: 'application/json' }) instead.

Getting duplicate events This is expected - implement idempotency using the X-SpeyBooks-Delivery header as described above.

Deliveries timing out Respond with 200 OK within 30 seconds. Offload heavy processing to a background queue.

5xx errors in delivery log Your handler is throwing an error. Check your application logs for the corresponding timestamp.


What to read next