# Webhooks & Real-Time Events 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. ## Prerequisites Before following this guide, make sure you have: - **Completed the [Getting Started guide](/online-ordering/guides/01-getting-started)** -- You have a Tote developer account and can obtain access tokens. - **An access token** -- See the [Authentication guide](/online-ordering/guides/02-authentication). - **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-ba9876543210 ``` ## Event Types Tote 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 | ### Event envelope All events share the same envelope structure. The `data` field contains the event-specific payload. ```json { "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`. | ## Managing Subscriptions 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. ### Creating a subscription Register your HTTPS endpoint and specify which event types you want to receive. ```python 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):** ```json { "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. ### Listing subscriptions Retrieve all your webhook subscriptions. Note that `signing_secret` is NOT included in the response. ```python 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):** ```json { "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 } } ``` ### Deleting a subscription Remove a subscription when you no longer need to receive events at that endpoint. This action cannot be undone. ```python 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. ### Multiple 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. ```python # 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"] }, ) ``` ### Subscription status 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. ## Verifying Signatures 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.** ### Signature format 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. | ### Verification steps 1. **Extract** the timestamp (`t`) and signature (`v1`) from the header. 2. **Check** that the timestamp is within 5 minutes (300 seconds) of the current time. Reject stale events to prevent replay attacks. 3. **Compute** the HMAC-SHA256 of `{timestamp}.{raw_body}` using your `signing_secret` as the key. The signed payload is the timestamp, a literal dot (`.`), then the raw request body bytes. 4. **Compare** your computed signature to the received `v1` value using a constant-time comparison function. ### Python ```python 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: ```python 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) ``` ### Node.js ```javascript 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: ```javascript 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); }); ``` ### Go ```go 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` (not `json.loads()` then `json.dumps()`). In Node.js, use `express.raw()`. In Go, use `io.ReadAll(r.Body)`. ## Handling Retries If your endpoint does not return a 2xx response within 30 seconds, Tote retries delivery with exponential backoff. ### Retry schedule | 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 | ### Retry rules - **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 `DISABLED` and 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. ## Processing Events Idempotently Network issues and retries mean your endpoint may receive the same event more than once. Your handler must process each event at most once. ### Why idempotency matters Consider this scenario: 1. Tote sends an `order.created` event to your endpoint. 2. Your endpoint processes the event and starts order preparation. 3. Your endpoint returns 200, but the response is lost due to a network issue. 4. Tote does not see the 200 and retries the delivery. 5. Without idempotency, your system starts a duplicate preparation workflow. ### Pattern: Deduplicate using event_id Every event has a unique `event_id`. Store processed IDs and check before processing. ```python 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) ``` ### Alternative: Database deduplication If you do not use Redis, use a database table with a unique constraint on `event_id`: ```sql 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. ## Event Payloads 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. ### order.created Fires when a new order is placed via checkout. Use this to start your order preparation workflow. ```json { "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. | ### order.status_changed Fires when any of the three status fields changes on an order. This is the primary event for order tracking. ```json { "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. ### stock.updated Fires when item availability changes at a location. Events are batched -- one event can report multiple items. ```json { "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`. ### Other event types 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](/online-ordering/guides/08-order-tracking#cancelling-an-order) 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](/online-ordering/guides/03-menu-sync) 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. ## Polling Fallback 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. ### Webhook-to-polling mapping 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 | ### Recommended polling strategy 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](/online-ordering/guides/08-order-tracking#polling-for-status-updates). ## Testing Webhooks Locally You can test your webhook verification logic locally without waiting for real events. ### Generate a test signature Use Python to compute a valid signature for a test payload: ```python 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}") ``` ### Send a test event with curl Use the generated signature to send a test event to your local handler: ```bash 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. ### Testing with real sandbox events To receive real webhook events from the sandbox environment on your local machine, use a tunneling tool to expose your local server: ```bash # Using ngrok ngrok http 8000 # Using cloudflared cloudflared tunnel --url http://localhost:8000 ``` Then 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. ## Best Practices - **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_secret` securely.** 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 `DISABLED` status. 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). ## Next Steps - [Order Tracking guide](/online-ordering/guides/08-order-tracking) -- Polling implementation with exponential backoff for order status monitoring. - [Checkout & Payments guide](/online-ordering/guides/06-checkout-payments) -- Payment submission and refund flows that generate order events. - [Getting Started guide](/online-ordering/guides/01-getting-started) -- Authentication and base URL setup. - [API Reference: Webhook Endpoints](/online-ordering/spec/openapi) -- Full endpoint specification for `POST /webhooks`, `GET /webhooks`, and `DELETE /webhooks/{webhook_id}`.