Back to Blog
engineering integrations webhooks

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.

J

James Park

Invalid Date

| 10 min read

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:

  1. You create an endpoint on your server (e.g., https://yourapp.com/webhooks/crumb)
  2. You register that URL in your POS dashboard
  3. When events happen, the POS sends HTTP POST requests to your URL
  4. 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:

  1. Accept POST requests with JSON body
  2. Respond with a 2xx status code within 30 seconds
  3. Be publicly accessible (not localhost, not behind auth)
  4. 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 created
  • v1: HMAC-SHA256 signature of the timestamp + payload

Verification Steps

  1. Parse the header to extract timestamp and signature
  2. Check that the timestamp is recent (within 5 minutes) to prevent replay attacks
  3. Compute the expected signature using your webhook secret
  4. 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:

  1. Use a message queue: Push events to a queue (Redis, SQS, RabbitMQ) immediately, process from the queue asynchronously. This decouples receiving from processing.

  2. Implement reconciliation: Periodically poll the API to catch any events that might have been missed during outages.

  3. 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:

  1. Verify signature validation works by sending a request with an invalid signature—it should reject
  2. Test idempotency by processing the same event twice—no duplicates should appear
  3. Test failure handling by returning 500 from your endpoint—verify retries arrive
  4. 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

  1. Check endpoint URL is correct and publicly accessible
  2. Verify no firewall is blocking incoming requests
  3. Check webhook is enabled for the event types you expect
  4. Look for failed deliveries in the dashboard

Signature verification failing

  1. Ensure you’re using the raw request body, not parsed JSON
  2. Verify you’re using the correct webhook secret (not API key)
  3. Check server clock is synchronized (NTP)

Duplicate events

  1. Implement idempotency as described above
  2. Check your processor isn’t too slow (causing timeouts and retries)
  3. Verify your 200 response is reaching CrumbPOS (no proxy issues)

Missing events

  1. Check if events failed delivery (dashboard shows failed webhooks)
  2. Implement reconciliation polling as a safety net
  3. 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.

Get Started