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.
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
| Event | Fires when |
|---|---|
invoice.created | A new invoice is created |
invoice.sent | An invoice is marked as sent |
invoice.paid | An invoice is fully paid |
invoice.overdue | An invoice passes its due date |
invoice.cancelled | An invoice is voided |
Quote events
| Event | Fires when |
|---|---|
quote.created | A new quote is created |
quote.accepted | A quote is accepted by the customer |
quote.rejected | A quote is rejected by the customer |
Contact events
| Event | Fires when |
|---|---|
contact.created | A new contact is created |
contact.updated | Contact details are changed |
Transaction events
| Event | Fires when |
|---|---|
transaction.created | A new transaction is created |
transaction.posted | A transaction status changes to posted |
Payment events
| Event | Fires when |
|---|---|
payment.received | A payment is received against an invoice |
Payload format
Every webhook delivery is an HTTP POST with these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-SpeyBooks-Event | Event type, e.g. invoice.created |
X-SpeyBooks-Signature | HMAC-SHA256 hex digest |
X-SpeyBooks-Timestamp | Unix timestamp of the event |
X-SpeyBooks-Delivery | Unique 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
- SpeyBooks concatenates the timestamp and raw request body:
${timestamp}.${body} - Signs that string with your
whsec_secret using HMAC-SHA256 - Sends the hex digest in the
X-SpeyBooks-Signatureheader
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
}
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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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:
Verify every signature. Never process an unverified payload. The verification examples above include timestamp checks and constant-time comparison - use them.
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.
Use HTTPS. SpeyBooks only delivers to HTTPS URLs. If your endpoint redirects HTTP to HTTPS, the POST body may be lost.
Store secrets in environment variables. Never commit
whsec_secrets to source control.Rotate secrets periodically. Use
POST /v1/webhooks/:id/rotateto generate a new secret. The old secret is invalidated immediately - update your handler before or immediately after rotating.Handle unknown events gracefully. If SpeyBooks adds new event types, your handler should log and ignore them rather than failing.
Deduplicate by delivery ID. The
X-SpeyBooks-Deliveryheader 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
- Webhooks API Reference - endpoint documentation
- Authentication - API key management
- Invoices API - the most common webhook trigger
- Idempotency - safe request retries