Webhooks push events to your server in real time as things happen -- orders placed, statuses changed, inventory updated. Instead of polling the API repeatedly, you register an HTTPS endpoint and Tote delivers events to it as they occur.
This guide covers the complete webhook integration: subscribing to events, verifying signatures, handling retries, processing events idempotently, and falling back to polling when needed.
Before following this guide, make sure you have:
- Completed the Getting Started guide -- You have a Tote developer account and can obtain access tokens.
- An access token -- See the Authentication guide.
- An HTTPS endpoint -- Your server must expose a publicly reachable HTTPS URL to receive webhook events. HTTP URLs are rejected.
All examples use the sandbox base URL and consistent IDs from prior guides:
Base URL: https://sandbox.api.tote.ai/v1/online-ordering
Location ID: b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d
Order ID: f9a8b7c6-d5e4-3210-fedc-ba9876543210Tote delivers six event types covering orders, inventory, menu, and location changes.
| Event Type | Fires When | Use Case |
|---|---|---|
order.created | New order placed via checkout | Start order preparation workflow |
order.status_changed | Any of the 3 status fields changes | Update order tracking UI |
order.cancelled | Order is cancelled | Stop preparation, process refunds |
stock.updated | Item availability changes at a location | Update menu availability display |
menu.changed | Menu items or categories modified | Re-sync local menu cache |
location.hours_changed | Business hours updated | Update store hours display |
All events share the same envelope structure. The data field contains the event-specific payload.
{
"event_id": "evt_c3d4e5f6-a7b8-9012-cdef-234567890123",
"event_type": "order.created",
"created_at": "2026-02-01T14:00:00Z",
"data": { ... }
}| Field | Type | Description |
|---|---|---|
event_id | UUID | Unique identifier for this event. Use for idempotent processing. |
event_type | string | Determines the shape of the data field. One of the six types listed above. |
created_at | ISO 8601 | When the event occurred (UTC). |
data | object | Event-specific payload. Shape depends on event_type. |
Webhook subscriptions are managed via REST endpoints. You create a subscription to register your endpoint, list subscriptions to check their status, and delete them when no longer needed.
Register your HTTPS endpoint and specify which event types you want to receive.
import requests
from uuid import uuid4
BASE_URL = "https://sandbox.api.tote.ai/v1/online-ordering"
headers = {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"Idempotency-Key": str(uuid4()),
}
response = requests.post(
f"{BASE_URL}/webhooks",
headers=headers,
json={
"url": "https://your-app.com/webhooks/tote",
"event_types": [
"order.created",
"order.status_changed",
"order.cancelled",
"stock.updated"
]
},
)
subscription = response.json()Response (201 Created):
{
"id": "wh_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-app.com/webhooks/tote",
"event_types": [
"order.created",
"order.status_changed",
"order.cancelled",
"stock.updated"
],
"status": "ACTIVE",
"signing_secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"created_at": "2026-02-01T10:00:00Z"
}IMPORTANT: The signing_secret is only returned in this response. It is not included in any subsequent GET requests. Store it immediately in a secure location (environment variable, secrets manager). You need it to verify webhook signatures.
Retrieve all your webhook subscriptions. Note that signing_secret is NOT included in the response.
response = requests.get(
f"{BASE_URL}/webhooks",
headers={"Authorization": "Bearer YOUR_ACCESS_TOKEN"},
)
subscriptions = response.json()
for sub in subscriptions["data"]:
print(f"{sub['id']} | {sub['status']} | {sub['url']}")
print(f" Events: {', '.join(sub['event_types'])}")Response (200 OK):
{
"data": [
{
"id": "wh_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-app.com/webhooks/tote",
"event_types": ["order.created", "order.status_changed", "order.cancelled", "stock.updated"],
"status": "ACTIVE",
"created_at": "2026-02-01T10:00:00Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
}Remove a subscription when you no longer need to receive events at that endpoint. This action cannot be undone.
webhook_id = "wh_a1b2c3d4-e5f6-7890-abcd-ef1234567890"
response = requests.delete(
f"{BASE_URL}/webhooks/{webhook_id}",
headers={"Authorization": "Bearer YOUR_ACCESS_TOKEN"},
)
# response.status_code == 204 (No Content)After deletion, any in-flight deliveries may still arrive at your endpoint. Your webhook handler should gracefully ignore events for unknown or deleted subscriptions.
You can create multiple subscriptions with different URLs and event type filters. This is useful for routing events to different services in a microservice architecture.
# Order events -> order processing service
requests.post(
f"{BASE_URL}/webhooks",
headers={**headers, "Idempotency-Key": str(uuid4())},
json={
"url": "https://your-app.com/orders/webhook",
"event_types": ["order.created", "order.status_changed", "order.cancelled"]
},
)
# Inventory events -> inventory service
requests.post(
f"{BASE_URL}/webhooks",
headers={**headers, "Idempotency-Key": str(uuid4())},
json={
"url": "https://your-app.com/inventory/webhook",
"event_types": ["stock.updated", "menu.changed"]
},
)Each subscription has a status field:
| Status | Description |
|---|---|
ACTIVE | Subscription is receiving events normally. |
DISABLED | Subscription has been automatically disabled after 50 consecutive delivery failures. |
When a subscription is DISABLED, events are no longer delivered to its URL. To resume delivery, create a new subscription with the same URL and event types. The new subscription will receive a fresh signing_secret.
Monitor your subscription status regularly by listing subscriptions and checking for DISABLED entries.
Every webhook delivery includes an X-Tote-Signature header for verifying the event came from Tote and was not tampered with. Always verify signatures before processing events.
The X-Tote-Signature header has this format:
X-Tote-Signature: t=1738443000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Component | Description |
|---|---|
t | Unix timestamp (seconds) when the event was sent. |
v1 | HMAC-SHA256 hex digest of the signed payload. |
- Extract the timestamp (
t) and signature (v1) from the header. - Check that the timestamp is within 5 minutes (300 seconds) of the current time. Reject stale events to prevent replay attacks.
- Compute the HMAC-SHA256 of
{timestamp}.{raw_body}using yoursigning_secretas the key. The signed payload is the timestamp, a literal dot (.), then the raw request body bytes. - Compare your computed signature to the received
v1value using a constant-time comparison function.
import hashlib
import hmac
import time
def verify_webhook_signature(
payload: bytes,
signature_header: str,
secret: str,
tolerance: int = 300,
) -> bool:
"""
Verify a Tote webhook signature.
Args:
payload: Raw request body bytes (do NOT parse and re-serialize).
signature_header: Value of the X-Tote-Signature header.
secret: Your webhook signing_secret from subscription creation.
tolerance: Maximum age of the event in seconds (default 5 minutes).
Returns:
True if the signature is valid and the timestamp is within tolerance.
"""
# Parse header: t=1738443000,v1=abc123...
parts = {}
for element in signature_header.split(","):
key, value = element.split("=", 1)
parts[key.strip()] = value.strip()
timestamp = parts.get("t")
received_signature = parts.get("v1")
if not timestamp or not received_signature:
return False
# Check timestamp tolerance (replay protection)
if abs(time.time() - int(timestamp)) > tolerance:
return False
# Compute expected signature: HMAC-SHA256 of "{timestamp}.{raw_body}"
signed_payload = f"{timestamp}.".encode() + payload
expected_signature = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# Constant-time comparison prevents timing attacks
return hmac.compare_digest(expected_signature, received_signature)Usage in a Django view:
from django.http import HttpResponse, HttpResponseForbidden
import json
import os
WEBHOOK_SECRET = os.environ["TOTE_WEBHOOK_SECRET"]
def webhook_handler(request):
# request.body is the raw bytes -- do NOT use request.POST or json.loads() for verification
signature = request.headers.get("X-Tote-Signature", "")
if not verify_webhook_signature(request.body, signature, WEBHOOK_SECRET):
return HttpResponseForbidden("Invalid signature")
event = json.loads(request.body)
# ... process event
return HttpResponse(status=200)const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, secret, tolerance = 300) {
// Parse header: t=1738443000,v1=abc123...
const parts = {};
signatureHeader.split(',').forEach(element => {
const [key, ...rest] = element.split('=');
parts[key.trim()] = rest.join('=').trim();
});
const timestamp = parts.t;
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) return false;
// Check timestamp tolerance (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > tolerance) {
return false;
}
// Compute expected signature: HMAC-SHA256 of "{timestamp}.{raw_body}"
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}Usage in an Express app:
const express = require('express');
const app = express();
// IMPORTANT: Use express.raw() to preserve the raw body bytes
app.post('/webhooks/tote', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-tote-signature'] || '';
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.TOTE_WEBHOOK_SECRET)) {
return res.status(403).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// ... process event
res.sendStatus(200);
});package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func VerifyWebhookSignature(rawBody []byte, signatureHeader, secret string, tolerance int64) bool {
// Parse header: t=1738443000,v1=abc123...
parts := make(map[string]string)
for _, element := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(element), "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp, ok := parts["t"]
if !ok {
return false
}
receivedSignature, ok := parts["v1"]
if !ok {
return false
}
// Check timestamp tolerance (replay protection)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > float64(tolerance) {
return false
}
// Compute expected signature: HMAC-SHA256 of "{timestamp}.{raw_body}"
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison prevents timing attacks
return hmac.Equal([]byte(expectedSignature), []byte(receivedSignature))
}
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
signature := r.Header.Get("X-Tote-Signature")
secret := "your_signing_secret" // Load from environment variable
if !VerifyWebhookSignature(body, signature, secret, 300) {
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// ... process event
w.WriteHeader(http.StatusOK)
}WARNING: Always verify against the raw request body bytes. Do NOT parse the JSON and re-serialize it -- key ordering, whitespace, or numeric precision differences will cause a signature mismatch. This is the number one webhook integration bug. In Python, use
request.body(notjson.loads()thenjson.dumps()). In Node.js, useexpress.raw(). In Go, useio.ReadAll(r.Body).
If your endpoint does not return a 2xx response within 30 seconds, Tote retries delivery with exponential backoff.
| Attempt | Delay After Previous | Time Since First Attempt |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 5 seconds | 5s |
| 3 | 30 seconds | 35s |
| 4 | 2 minutes | 2m 35s |
| 5 | 15 minutes | 17m 35s |
| 6 | 1 hour | 1h 17m 35s |
| 7 | 4 hours | 5h 17m 35s |
- Success: Return any 2xx status code within 30 seconds. The event is considered delivered.
- Failure: Any non-2xx response, connection error, or timeout (>30 seconds) triggers a retry.
- Exhaustion: After 7 attempts (the initial delivery plus 6 retries), the event is dropped for that subscription.
- Automatic disabling: After 50 consecutive failed events across any deliveries, the subscription status changes to
DISABLEDand all further deliveries stop.
Tip: Return 200 immediately and process the event asynchronously in a background queue. This avoids timeouts and ensures retries are only triggered by genuine failures.
Network issues and retries mean your endpoint may receive the same event more than once. Your handler must process each event at most once.
Consider this scenario:
- Tote sends an
order.createdevent to your endpoint. - Your endpoint processes the event and starts order preparation.
- Your endpoint returns 200, but the response is lost due to a network issue.
- Tote does not see the 200 and retries the delivery.
- Without idempotency, your system starts a duplicate preparation workflow.
Every event has a unique event_id. Store processed IDs and check before processing.
import redis
import json
import os
redis_client = redis.Redis()
WEBHOOK_SECRET = os.environ["TOTE_WEBHOOK_SECRET"]
EVENT_HANDLERS = {
"order.created": "handle_order_created",
"order.status_changed": "handle_order_status_changed",
"order.cancelled": "handle_order_cancelled",
"stock.updated": "handle_stock_updated",
"menu.changed": "handle_menu_changed",
"location.hours_changed": "handle_location_hours_changed",
}
def webhook_endpoint(request):
"""Complete webhook handler with verification, deduplication, and routing."""
# Step 1: Verify signature
signature = request.headers.get("X-Tote-Signature", "")
if not verify_webhook_signature(request.body, signature, WEBHOOK_SECRET):
return HttpResponseForbidden("Invalid signature")
event = json.loads(request.body)
event_id = event["event_id"]
# Step 2: Check for duplicate (7-day deduplication window)
cache_key = f"webhook:processed:{event_id}"
if redis_client.get(cache_key):
# Already processed -- return 200 so Tote does not retry
return HttpResponse(status=200)
# Step 3: Route to event-type handler
event_type = event["event_type"]
handler_name = EVENT_HANDLERS.get(event_type)
if handler_name:
handler = globals()[handler_name]
handler(event["data"])
# Step 4: Mark as processed (7-day TTL = 604800 seconds)
redis_client.setex(cache_key, 604800, "1")
# Step 5: Return 200
return HttpResponse(status=200)If you do not use Redis, use a database table with a unique constraint on event_id:
CREATE TABLE processed_webhook_events (
event_id UUID PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Clean up events older than 7 days
DELETE FROM processed_webhook_events
WHERE processed_at < NOW() - INTERVAL '7 days';Check for the event_id before processing. If the INSERT fails with a unique constraint violation, the event was already processed.
This section shows complete payload examples for the three most common event types, with field-level annotations. For all six event types, see the API reference.
Fires when a new order is placed via checkout. Use this to start your order preparation workflow.
{
"event_id": "evt_c3d4e5f6-a7b8-9012-cdef-234567890123",
"event_type": "order.created",
"created_at": "2026-02-01T14:00:00Z",
"data": {
"order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
"location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
"status": "PENDING",
"handoff_mode": "CURBSIDE",
"total": {
"amount": 1945,
"currency": "USD"
},
"created_at": "2026-02-01T14:00:00Z"
}
}| Field | Description |
|---|---|
data.order_id | The new order's ID. Fetch full details with GET /orders/{orderId}. |
data.location_id | Which store the order was placed at. |
data.status | Always PENDING at creation -- payment has not been submitted yet. |
data.handoff_mode | How the customer wants to receive the order (PICKUP, CURBSIDE, DELIVERY, or KIOSK). |
data.total | Order total in cents. 1945 = $19.45. |
Fires when any of the three status fields changes on an order. This is the primary event for order tracking.
{
"event_id": "evt_d4e5f6a7-b8c9-0123-def0-345678901234",
"event_type": "order.status_changed",
"created_at": "2026-02-01T14:30:00Z",
"data": {
"order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
"location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
"previous_status": "CONFIRMED",
"current_status": "COMPLETED",
"previous_fulfillment_status": "READY_FOR_PICKUP",
"current_fulfillment_status": "FULFILLED",
"previous_payment_status": "PAID",
"current_payment_status": "PAID",
"updated_at": "2026-02-01T14:30:00Z"
}
}| Field | Description |
|---|---|
data.previous_status / data.current_status | The overall order lifecycle status before and after the change. |
data.previous_fulfillment_status / data.current_fulfillment_status | Physical preparation and handoff status. This is the field to display in customer-facing tracking UI. |
data.previous_payment_status / data.current_payment_status | Aggregate payment state. Check this for payment confirmation. |
The event includes both previous and current values for all three status fields, so you can determine exactly what changed without fetching the order.
Fires when item availability changes at a location. Events are batched -- one event can report multiple items.
{
"event_id": "evt_f6a7b8c9-d0e1-2345-f012-567890123456",
"event_type": "stock.updated",
"created_at": "2026-02-01T13:45:00Z",
"data": {
"location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
"items": [
{
"item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Build Your Own Sub Sandwich",
"previous_status": "IN_STOCK",
"current_status": "LOW_STOCK",
"updated_at": "2026-02-01T13:45:00Z"
},
{
"item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
"name": "Bottled Water",
"previous_status": "IN_STOCK",
"current_status": "OUT_OF_STOCK",
"updated_at": "2026-02-01T13:45:00Z"
}
]
}
}Each item includes previous_status and current_status using the three-level availability enum: IN_STOCK, LOW_STOCK, OUT_OF_STOCK. Use current_status to update your menu display -- hide or gray out items that are OUT_OF_STOCK, and optionally show a "limited availability" indicator for LOW_STOCK.
The remaining three event types follow the same envelope structure:
order.cancelled -- Includes order_id, location_id, reason, and cancelled_at. See the Order Tracking guide for cancellation details.
menu.changed -- Includes location_id, version_hash, change_type (ITEMS_MODIFIED, CATEGORIES_MODIFIED, or FULL_UPDATE), and changed_at. Compare version_hash with your cached value to determine if a re-sync is needed. See the Menu Synchronization guide for sync strategies.
location.hours_changed -- Includes location_id, full previous_hours and current_hours arrays, effective_date, and changed_at. The complete hours arrays are included so you can diff them without fetching the location.
Webhooks are best-effort delivery. Always implement polling as a safety net to catch events that may have been missed due to delivery failures, network issues, or subscription downtime.
Every webhook event type has a corresponding polling endpoint:
| Webhook Event | Polling Endpoint | What to Compare |
|---|---|---|
order.created | GET /orders?date_from={last_poll} | New orders since last poll |
order.status_changed | GET /orders/{orderId} | status, fulfillment_status, payment_status |
order.cancelled | GET /orders/{orderId} | status == CANCELLED |
stock.updated | GET /locations/{locationId}/inventory | availability_status per item |
menu.changed | GET /locations/{locationId}/menu/metadata | version_hash |
location.hours_changed | GET /locations/{locationId} | business_hours array |
Use webhooks as the primary delivery mechanism and polling as a reconciliation layer:
- When webhooks are working: Poll every 5 minutes as a background reconciliation check. Compare the polling results with what your webhook handler has already processed.
- When webhooks fail: If you detect missed events (e.g., your subscription status is
DISABLED), switch to polling at 30-second intervals until the subscription is restored.
For detailed polling implementation with exponential backoff, see the Order Tracking guide.
You can test your webhook verification logic locally without waiting for real events.
Use Python to compute a valid signature for a test payload:
import hashlib
import hmac
import json
import time
secret = "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
timestamp = str(int(time.time()))
payload = json.dumps({
"event_id": "evt_test-0001-0001-0001-000000000001",
"event_type": "order.created",
"created_at": "2026-02-01T14:00:00Z",
"data": {
"order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
"location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
"status": "PENDING",
"handoff_mode": "PICKUP",
"total": {"amount": 1945, "currency": "USD"},
"created_at": "2026-02-01T14:00:00Z"
}
})
signed_payload = f"{timestamp}.{payload}"
signature = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
print(f"X-Tote-Signature: t={timestamp},v1={signature}")
print(f"Payload: {payload}")Use the generated signature to send a test event to your local handler:
curl -X POST http://localhost:8000/webhooks/tote \
-H "Content-Type: application/json" \
-H "X-Tote-Signature: t=1738443000,v1=YOUR_GENERATED_SIGNATURE" \
-d '{"event_id":"evt_test-0001-0001-0001-000000000001","event_type":"order.created","created_at":"2026-02-01T14:00:00Z","data":{"order_id":"f9a8b7c6-d5e4-3210-fedc-ba9876543210","location_id":"b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d","status":"PENDING","handoff_mode":"PICKUP","total":{"amount":1945,"currency":"USD"},"created_at":"2026-02-01T14:00:00Z"}}'Your handler should return 200 if the signature is valid.
To receive real webhook events from the sandbox environment on your local machine, use a tunneling tool to expose your local server:
# Using ngrok
ngrok http 8000
# Using cloudflared
cloudflared tunnel --url http://localhost:8000Then create a webhook subscription using the tunnel URL as the endpoint. Sandbox events (from test orders, menu changes, etc.) will be delivered to your local server.
- Always verify signatures before processing. Never skip verification, even in development. A compromised endpoint can inject fake events into your system.
- Process events idempotently using
event_id. Store processed IDs with a 7-day TTL and check before processing. This protects against duplicate deliveries from retries. - Return 200 quickly, defer heavy work. Acknowledge the event immediately and push processing to a background queue. This avoids timeouts and unnecessary retries.
- Implement polling fallback alongside webhooks. Webhooks are best-effort. Poll every 5 minutes as a reconciliation check to catch missed events.
- Store
signing_secretsecurely. Use environment variables or a secrets manager. Never hardcode secrets in source code or log them. - Monitor subscription health. Periodically list your subscriptions and check for
DISABLEDstatus. A disabled subscription means you are missing events. - Use event-type filtering. Only subscribe to the event types you need. This reduces traffic to your endpoint and simplifies your handler logic.
- Handle unknown event types gracefully. If Tote adds new event types in the future, your handler should return 200 for unrecognized types rather than returning an error (which would trigger retries).
- Order Tracking guide -- Polling implementation with exponential backoff for order status monitoring.
- Checkout & Payments guide -- Payment submission and refund flows that generate order events.
- Getting Started guide -- Authentication and base URL setup.
- API Reference: Webhook Endpoints -- Full endpoint specification for
POST /webhooks,GET /webhooks, andDELETE /webhooks/{webhook_id}.