Webhooks 101: Syncing Orders to Your Stack
A practical guide to receiving real-time order data from your POS and integrating it with your own systems, databases, and workflows.
James Park
Invalid Date
If you’re building custom integrations with your POS, you have two options for getting order data: polling the API repeatedly, or receiving webhooks.
Polling works, but it’s inefficient. You’re constantly asking “any new orders?” even when nothing has changed. You’re burning API quota, adding server load, and still not getting truly real-time data—there’s always the gap between polls.
Webhooks flip the model. Instead of you asking for updates, the POS pushes updates to you the moment they happen. Order created? You know instantly. Payment completed? Same. Item voided? You find out before the kitchen does.
This guide covers everything you need to implement a production-ready webhook receiver.
How Webhooks Work
The concept is simple:
- You create an endpoint on your server (e.g.,
https://yourapp.com/webhooks/crumb) - You register that URL in your POS dashboard
- When events happen, the POS sends HTTP POST requests to your URL
- Your endpoint receives the event data and does something with it
The challenge is in the details: reliability, security, error handling, and idempotency.
Setting Up Your Endpoint
Your webhook endpoint needs to:
- Accept POST requests with JSON body
- Respond with a 2xx status code within 30 seconds
- Be publicly accessible (not localhost, not behind auth)
- Verify webhook signatures before processing
Here’s a minimal example in Node.js with Express:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Use raw body for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks/crumb', (req, res) => {
const signature = req.headers['crumb-signature'];
const body = req.body;
// Verify signature (we'll cover this in detail below)
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(body);
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process asynchronously
processEvent(event);
});
app.listen(3000);
Respond Fast
Your endpoint must respond within 30 seconds or the request times out. Return 200 immediately, then process asynchronously. Never do slow operations (database writes, external API calls) before responding.
Signature Verification
Every webhook includes a cryptographic signature in the Crumb-Signature header. This proves the webhook came from CrumbPOS, not an attacker.
The header format is:
Crumb-Signature: t=1697654321,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
It contains:
t: Unix timestamp when the signature was createdv1: HMAC-SHA256 signature of the timestamp + payload
Verification Steps
- Parse the header to extract timestamp and signature
- Check that the timestamp is recent (within 5 minutes) to prevent replay attacks
- Compute the expected signature using your webhook secret
- Compare the computed signature with the received signature
function verifySignature(payload, header, secret) {
// Parse header
const parts = header.split(',');
const timestamp = parts[0].split('=')[1];
const receivedSignature = parts[1].split('=')[1];
// Check timestamp freshness (5 minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures (timing-safe comparison)
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
Never skip signature verification. Without it, anyone who discovers your endpoint URL can send fake events.
Handling Common Event Types
CrumbPOS sends webhooks for various events. Here are the most commonly used:
order.created
Fired when a new order is placed (POS, online, or third-party).
{
"id": "evt_abc123",
"type": "order.created",
"created_at": "2024-10-05T14:30:00Z",
"data": {
"id": "ord_xyz789",
"type": "order",
"attributes": {
"status": "open",
"source": "pos",
"total": 4250,
"tax": 340,
"items": [
{
"id": "oi_111",
"menu_item_id": "mi_burger",
"name": "Burger Deluxe",
"quantity": 2,
"price": 1500,
"modifiers": [
{ "id": "mod_no_onions", "name": "No Onions", "price": 0 }
]
}
],
"location_id": "loc_main",
"created_at": "2024-10-05T14:30:00Z"
}
}
}
order.updated
Fired when an order is modified (items added/removed, status changed).
payment.completed
Fired when payment is successfully processed.
{
"id": "evt_def456",
"type": "payment.completed",
"created_at": "2024-10-05T14:32:00Z",
"data": {
"id": "pay_ghi789",
"type": "payment",
"attributes": {
"order_id": "ord_xyz789",
"amount": 4250,
"tip": 850,
"method": "card",
"card_brand": "visa",
"card_last_four": "4242",
"status": "completed"
}
}
}
inventory.low_stock
Fired when an ingredient or menu item hits the low stock threshold.
Building an Idempotent Processor
Network issues can cause webhooks to be delivered multiple times. Your processor must handle duplicates gracefully.
The key is idempotency: processing the same event twice should have the same result as processing it once.
Using Event IDs
Every webhook includes a unique event ID. Store processed event IDs and check before processing:
async function processEvent(event) {
// Check if we've already processed this event
const exists = await db.query(
'SELECT 1 FROM processed_events WHERE event_id = ?',
[event.id]
);
if (exists) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process the event
await handleEvent(event);
// Mark as processed
await db.query(
'INSERT INTO processed_events (event_id, processed_at) VALUES (?, NOW())',
[event.id]
);
}
Idempotent Database Operations
Even with event ID checking, design your operations to be idempotent:
// BAD: Not idempotent
async function handleOrderCreated(order) {
await db.query('INSERT INTO orders VALUES (?)', [order]);
// Second execution will fail with duplicate key
}
// GOOD: Idempotent with upsert
async function handleOrderCreated(order) {
await db.query(`
INSERT INTO orders (id, data)
VALUES (?, ?)
ON CONFLICT (id) DO UPDATE SET data = ?
`, [order.id, order, order]);
// Second execution succeeds and updates existing row
}
Retry Handling and Failure Recovery
When your endpoint returns non-2xx or times out, CrumbPOS retries with exponential backoff:
- Retry 1: 1 minute after failure
- Retry 2: 5 minutes after retry 1
- Retry 3: 30 minutes after retry 2
After 3 failures, the webhook is marked as failed. You can manually retry failed webhooks from the dashboard.
Designing for Failure
Your system should handle temporary webhook outages gracefully:
-
Use a message queue: Push events to a queue (Redis, SQS, RabbitMQ) immediately, process from the queue asynchronously. This decouples receiving from processing.
-
Implement reconciliation: Periodically poll the API to catch any events that might have been missed during outages.
-
Monitor delivery: Track webhook receipt rate and alert on anomalies.
// Queue-based architecture
const Queue = require('bull');
const webhookQueue = new Queue('webhooks');
app.post('/webhooks/crumb', (req, res) => {
// Verify signature
if (!verifySignature(req.body, req.headers['crumb-signature'], secret)) {
return res.status(401).send('Invalid signature');
}
// Add to queue immediately
webhookQueue.add(JSON.parse(req.body));
// Respond fast
res.status(200).send('OK');
});
// Process from queue with retries
webhookQueue.process(async (job) => {
await processEvent(job.data);
});
webhookQueue.on('failed', (job, err) => {
console.error(`Event ${job.data.id} failed:`, err);
// Alert your monitoring system
});
Testing Your Integration
Local Development
During development, use a tool like ngrok to expose your local server:
ngrok http 3000
This gives you a public URL that tunnels to localhost. Register this URL in your CrumbPOS sandbox.
Test Events
Use the test webhook feature to send sample events:
curl -X POST https://api.crumbpos.com/v1/webhooks/test \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"endpoint_id": "we_abc123",
"event_type": "order.created"
}'
This sends a test event with sample data to your registered endpoint.
Production Validation
Before going live:
- Verify signature validation works by sending a request with an invalid signature—it should reject
- Test idempotency by processing the same event twice—no duplicates should appear
- Test failure handling by returning 500 from your endpoint—verify retries arrive
- Load test your endpoint with concurrent requests to ensure it handles bursts
Common Patterns
Syncing to a Data Warehouse
Many integrations sync order data to a data warehouse for analytics:
async function handleOrderCompleted(order) {
const record = {
order_id: order.id,
location_id: order.attributes.location_id,
total_cents: order.attributes.total,
item_count: order.attributes.items.length,
created_at: order.attributes.created_at,
completed_at: new Date().toISOString()
};
await bigquery.insert('orders', record);
}
Triggering Notifications
Send alerts when certain events occur:
async function handleLowStock(event) {
const item = event.data.attributes;
await slack.send('#inventory-alerts', {
text: `⚠️ Low stock: ${item.name} at ${item.location_name}`,
fields: [
{ title: 'Current Level', value: item.quantity },
{ title: 'Reorder Point', value: item.reorder_point }
]
});
}
Updating External Systems
Keep external systems (kitchen displays, loyalty programs, etc.) in sync:
async function handleOrderCreated(order) {
// Push to external KDS
await kdsApi.createTicket({
external_id: order.id,
items: order.attributes.items.map(formatItem),
priority: order.attributes.source === 'delivery' ? 'high' : 'normal'
});
// Award loyalty points
if (order.attributes.customer_id) {
await loyaltyApi.awardPoints(
order.attributes.customer_id,
Math.floor(order.attributes.total / 100)
);
}
}
Troubleshooting
Webhooks not arriving
- Check endpoint URL is correct and publicly accessible
- Verify no firewall is blocking incoming requests
- Check webhook is enabled for the event types you expect
- Look for failed deliveries in the dashboard
Signature verification failing
- Ensure you’re using the raw request body, not parsed JSON
- Verify you’re using the correct webhook secret (not API key)
- Check server clock is synchronized (NTP)
Duplicate events
- Implement idempotency as described above
- Check your processor isn’t too slow (causing timeouts and retries)
- Verify your 200 response is reaching CrumbPOS (no proxy issues)
Missing events
- Check if events failed delivery (dashboard shows failed webhooks)
- Implement reconciliation polling as a safety net
- Verify you’re subscribed to all needed event types
Webhooks transform your POS from a standalone system into a real-time event stream that powers your entire operation. Once you’ve built a solid webhook receiver, the integration possibilities are endless.
Need help with your integration? Contact our developer relations team or check out our full API documentation.
Ready to transform your restaurant?
Join thousands of restaurants using Crumb to streamline operations and delight customers.