SmartQ.tv Developer API
Customer API / Webhooks / BevQ

Customer API webhooks: BevQ

Last updated: September 4, 2026

Use BevQ webhooks to receive a near-real-time prompt when an authoritative BevQ location menu changes. The webhook is deliberately small: it identifies the affected location and menu revision. Your trusted backend verifies the webhook, deduplicates it, obtains or reuses a Customer API OAuth access token, and retrieves the current menu resource. The webhook payload is not the menu and is not a substitute for periodic reconciliation.

Related documentation:

Supported events

Event typePurposeTriggerModule requirement
bevq.menu.updatedProduction change notificationA rebuilt location menu has a new externally published revision. The event ID is deterministic for the group, location, and revision, so several invalidations that produce the same revision do not create separate logical events.Active client_credentials application with bevq:read
customer_api.webhook.testSafe management testA Developer API administrator selects Send test for an enabled destination and an authorized location. It uses synthetic event identity and does not change BevQ, POS, or menu data.Existing eligible, enabled destination

No other Customer API webhook event types are currently supported. Each event is location-specific; SmartQ does not send a combined group-menu event.

Application requirement

A new webhook destination must belong to an active, customer-managed client_credentials application with bevq:read. Revoked or inactive applications, applications without BevQ, and Support-managed legacy_bearer credentials are not eligible to own a new destination. Existing product-module ownership rules still apply.

Webhook signing and Customer API authorization are independent:

Destination management

In SmartQ Admin, open Settings -> API Access -> Customer API Webhooks.

  1. Select Add destination.
  2. Choose an eligible BevQ-enabled application.
  3. Enter a descriptive name and a public HTTPS destination URL.
  4. Choose All authorized locations or Selected authorized locations when the application is group-scoped.
  5. Leave Enabled selected and save the destination.
  6. Copy the one-time Webhook Signing Secret directly into the receiver's server-side secret manager.
  7. Use Send test and verify the signed test request before relying on production events.

A location-scoped application can subscribe only for its one authorized location. A group application without an explicit location allowlist can cover all current and future locations in the group. A selected-location subscription never expands automatically. For a group application with an explicit allowlist, All authorized locations means only that allowlist.

The current UI supports:

The signing secret is 32 random bytes encoded as base64url. It is returned only when the destination is first created or explicitly rotated, cannot be retrieved later, and is never included in notification email. Store it server-side, never expose it in browser or mobile code, never log it, and never send it back to SmartQ Support.

Destination URLs must use HTTPS. URLs with embedded credentials or fragments are rejected. SmartQ validates DNS before saving and again before every attempt, blocks local, private, reserved, and metadata addresses, connects only to a validated public address while preserving the original hostname for TLS, and does not follow redirects.

HTTP request and headers

SmartQ sends an HTTPS POST request with these stable headers:

HeaderExampleMeaning
Content-Typeapplication/json; charset=utf-8JSON body encoded as UTF-8
User-AgentSmartQ-Customer-API-Webhooks/1.0SmartQ Customer API webhook sender
X-SmartQ-Eventbevq.menu.updatedEvent type; test deliveries use customer_api.webhook.test
X-SmartQ-Event-Id8c34...d970Stable event identifier used for idempotency
X-SmartQ-Timestamp1788480000Unix time in seconds as a decimal string, generated for this delivery attempt
X-SmartQ-Signaturesha256=4f1a...9c20HMAC-SHA256 signature described below

SmartQ does not currently send a delivery ID, attempt number, or API-version header.

Representative production request using non-production identifiers:

POST /smartq/webhooks/bevq HTTP/1.1
Host: integration.example.com
Content-Type: application/json; charset=utf-8
User-Agent: SmartQ-Customer-API-Webhooks/1.0
X-SmartQ-Event: bevq.menu.updated
X-SmartQ-Event-Id: 8c34a5f77e17f55ea39d6f126998c842809ec3d713f8ff52b243b42d7052d970
X-SmartQ-Timestamp: 1788480000
X-SmartQ-Signature: sha256=4f1a0123456789abcdef0123456789abcdef0123456789abcdef012345679c20

{"eventId":"8c34a5f77e17f55ea39d6f126998c842809ec3d713f8ff52b243b42d7052d970","eventType":"bevq.menu.updated","occurredAt":"2026-09-04T04:00:00.000Z","groupId":"group_demo","locationId":"location_demo","revision":"menu_revision_demo","resource":"/v1/group_demo/location_demo/bevq/menu"}

The sample signature is illustrative, not a digest for a real secret.

Payload schema

FieldTypeRequiredMeaningExample
eventIdstringYesStable logical event ID. Production menu events derive it from group, location, and revision; test events receive a synthetic ID.8c34...d970
eventTypestringYesbevq.menu.updated for production or customer_api.webhook.test for a safe test.bevq.menu.updated
occurredAtstringYesISO 8601 timestamp for event creation.2026-09-04T04:00:00.000Z
groupIdstringYesSmartQ location-group identifier.group_demo
locationIdstringYesAffected authorized location.location_demo
revisionstringYesCurrent externally published menu revision. It is an empty string on a test event because the test does not represent a real menu revision.menu_revision_demo
resourcestringYesCustomer API path to retrieve the affected menu./v1/group_demo/location_demo/bevq/menu
testbooleanNoPresent as true only for customer_api.webhook.test; omitted from production events.true

The payload does not include a full menu, price data, POS credentials, OAuth credentials, webhook secrets, change reasons, or field-level diffs.

Safe test payload:

{
  "eventId": "ba480de66c6d1933af975a1f06108d840fb6721e760307ee5bcd393238614f0a",
  "eventType": "customer_api.webhook.test",
  "occurredAt": "2026-09-04T04:05:00.000Z",
  "groupId": "group_demo",
  "locationId": "location_demo",
  "revision": "",
  "resource": "/v1/group_demo/location_demo/bevq/menu",
  "test": true
}

Signature verification

The signature contract is:

PropertyContract
AlgorithmHMAC-SHA256
SecretThe destination's independently generated Webhook Signing Secret
Signed inputX-SmartQ-Timestamp + "." + rawRequestBody
Character encodingUTF-8
Digest encodingLowercase hexadecimal
Header prefixsha256=
Final valuesha256=<64 lowercase hexadecimal characters>

Conceptually:

expected = "sha256=" + lowercase_hex(
  HMAC_SHA256(webhook_signing_secret, utf8(timestamp + "." + raw_request_body))
)

HTTP header names are case-insensitive. Treat the timestamp and signature as untrusted input. Validate the sha256= format and equal lengths before a timing-safe comparison.

SmartQ signs each delivery attempt with a fresh Unix-seconds timestamp. SmartQ cannot enforce replay policy inside your receiver. A five-minute timestamp tolerance is recommended, not a sender-enforced protocol requirement. After verifying the signature, reject timestamps outside your chosen window and retain processed event IDs long enough for your idempotency policy.

Node.js verification example

This example uses only Node's standard crypto module. rawBody must be the exact Buffer received from the network.

import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_SKEW_SECONDS = 300; // Recommended receiver policy.

export function verifySmartQWebhook({ rawBody, headers, signingSecret, nowSeconds = Math.floor(Date.now() / 1000) }) {
  if (!Buffer.isBuffer(rawBody)) throw new Error("rawBody must be a Buffer");

  const timestamp = String(headers["x-smartq-timestamp"] || "");
  const supplied = String(headers["x-smartq-signature"] || "");
  const eventId = String(headers["x-smartq-event-id"] || "");
  if (!/^\d+$/.test(timestamp) || !/^sha256=[a-f0-9]{64}$/.test(supplied) || !eventId) return false;
  if (Math.abs(nowSeconds - Number(timestamp)) > MAX_SKEW_SECONDS) return false;

  const mac = createHmac("sha256", Buffer.from(signingSecret, "utf8"));
  mac.update(Buffer.from(`${timestamp}.`, "utf8"));
  mac.update(rawBody);
  const expected = `sha256=${mac.digest("hex")}`;
  const expectedBytes = Buffer.from(expected, "ascii");
  const suppliedBytes = Buffer.from(supplied, "ascii");
  return expectedBytes.length === suppliedBytes.length && timingSafeEqual(expectedBytes, suppliedBytes);
}

export async function handleSmartQWebhook(request, eventStore) {
  const rawBody = request.rawBody; // Exact Buffer captured before a JSON parser.
  if (!verifySmartQWebhook({ rawBody, headers: request.headers, signingSecret: process.env.SMARTQ_WEBHOOK_SIGNING_SECRET })) {
    return { status: 401, body: "Invalid webhook" };
  }

  const eventId = String(request.headers["x-smartq-event-id"]);
  if (await eventStore.has(eventId)) return { status: 204 };
  const event = JSON.parse(rawBody.toString("utf8"));
  await processVerifiedEvent(event);
  await eventStore.record(eventId);
  return { status: 204 };
}

eventStore and processVerifiedEvent represent your durable idempotency and processing components. Record the event ID atomically with, or after, successful processing according to your failure model.

Python verification example

Keep the request body as bytes. Python's hmac.compare_digest performs the timing-safe comparison.

import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 300  # Recommended receiver policy.

def verify_smartq_webhook(raw_body: bytes, headers, signing_secret: str) -> bool:
    timestamp = headers.get("X-SmartQ-Timestamp", "")
    supplied = headers.get("X-SmartQ-Signature", "")
    event_id = headers.get("X-SmartQ-Event-Id", "")
    if not timestamp.isdigit() or not event_id:
        return False
    if len(supplied) != 71 or not supplied.startswith("sha256="):
        return False
    if abs(int(time.time()) - int(timestamp)) > MAX_SKEW_SECONDS:
        return False

    signed = timestamp.encode("utf-8") + b"." + raw_body
    digest = hmac.new(signing_secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    expected = "sha256=" + digest
    return hmac.compare_digest(expected, supplied)

# Flask: raw_body = request.get_data(cache=True, as_text=False)
# FastAPI/Starlette: raw_body = await request.body()
# Verify before request.get_json(), request.json(), or model binding.

After verification, deduplicate using X-SmartQ-Event-Id, parse with json.loads(raw_body), process the event, record the ID durably, and return a 2xx response.

PHP verification example

php://input returns the raw request body. Read it once, verify it, and only then call json_decode.

<?php
$secret = (string) getenv('SMARTQ_WEBHOOK_SIGNING_SECRET');
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SMARTQ_TIMESTAMP'] ?? '';
$supplied = $_SERVER['HTTP_X_SMARTQ_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_SMARTQ_EVENT_ID'] ?? '';
$maxSkewSeconds = 300; // Recommended receiver policy.

if ($secret === '' || $rawBody === false) {
    http_response_code(500);
    exit;
}

$validFormat = preg_match('/^\d+$/', $timestamp)
    && preg_match('/^sha256=[a-f0-9]{64}$/', $supplied)
    && $eventId !== '';
$fresh = $validFormat && abs(time() - (int) $timestamp) <= $maxSkewSeconds;
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (!$fresh || !hash_equals($expected, $supplied)) {
    http_response_code(401);
    echo 'Invalid webhook';
    exit;
}

// Check $eventId in durable storage before processing.
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
process_verified_smartq_event($event);
record_processed_event_id($eventId);
http_response_code(204);

The storage and processing functions are application-specific placeholders.

Framework raw-body notes

Idempotency and processing order

Delivery is at least once. The same event can be delivered more than once because a response can be lost or a retryable failure can occur after your system has processed the event.

Recommended receiver order:

  1. Capture the raw body and headers.
  2. Validate the timestamp format and your recommended replay window.
  3. Verify the HMAC signature with a constant-time comparison.
  4. Check X-SmartQ-Event-Id in durable storage.
  5. If it is already processed, return a 2xx response without repeating side effects.
  6. Parse and process the event.
  7. Record the event ID durably.
  8. Return a 2xx response.

Retrieve the authoritative menu

For bevq.menu.updated:

  1. Verify the webhook and deduplicate the event.
  2. Confirm that eventType, groupId, locationId, and resource match the integration's expected SmartQ scope. Do not treat resource as an arbitrary external URL.
  3. Reuse a valid OAuth access token or exchange the application's Client ID and Client Secret at /oauth/token.
  4. Call https://api.smartq.tv plus the supplied resource path from your trusted backend. Send the last stored ETag with If-None-Match when available.
  5. On 200, replace the local projection and store the new ETag/revision. On 304, keep the current projection.
  6. Keep a scheduled conditional reconciliation job across every authorized location so a missed webhook cannot permanently desynchronize the local tap list.

For the Art & Jake's pattern, SmartQ sends the signed location prompt; the receiver verifies and deduplicates it; the server-side integration uses its client-credentials token to retrieve the authoritative menu; and the local tap-list system updates from that API response.

Success, timeout, and retries

Any HTTP status from 200 through 299 is a successful delivery. Return a 2xx response as quickly as practical after safely accepting the event. If processing can be slow, persist the verified event to an internal queue, return 2xx, and process asynchronously.

SmartQ waits 10 seconds for each attempt. It does not follow redirects.

SmartQ retries:

Other non-2xx responses, including redirects and other 4xx responses, are permanent failures. Retryable deliveries make at most five total attempts. The task queue uses bounded exponential backoff with a 30-second minimum, a 3,600-second maximum, and at most five doublings; exact attempt times are controlled by the task service and are not a delivery-time SLA. After the fifth retryable failure, the delivery becomes DEAD_LETTER.

Disabling, removal, and application revocation

Pending task records are not synchronously deleted during these management actions. Safety comes from re-validating current state at execution time so an ineligible attempt is not transmitted.

Delivery observability

SmartQ Admin shows customer-visible operational metadata without exposing secrets or full destination URLs:

Delivery records can use PENDING, DELIVERING, RETRY_PENDING, SUCCEEDED, FAILED, or DEAD_LETTER. A successful attempt sets health to HEALTHY and resets consecutive failures. A failed attempt sets health to FAILING and increments consecutive failures. Full response bodies are not retained or displayed.

Creating, updating, and removing a destination generates the corresponding Customer API notification email for the verified application owner, with SmartQ Support BCC according to the transactional-email policy. Emails include customer-safe configuration context but never the signing secret, OAuth Client Secret, access token, full Authorization header, or full response body. Secret rotation and Send test do not generate these configuration emails.

Troubleshooting

Signature does not match

Event arrives more than once

This is expected under at-least-once delivery. Deduplicate durable processing with X-SmartQ-Event-Id and return 2xx for an event already completed.

Customer API returns 401 or 403

Webhook verification and Customer API authorization are separate. Check the OAuth access token, parent application status, bevq:read, and the requested group/location authorization. Do not send the Webhook Signing Secret as an API bearer.

Webhook is not received

Check that the destination is enabled, its parent client_credentials application is active with BevQ, its location coverage includes the affected location, and the URL is publicly reachable over trusted HTTPS. Review destination health, last failure, recent delivery status, and safe error code in SmartQ Admin. Ensure the receiver returns within 10 seconds.

Test succeeds but no production event arrives

A production event is emitted only when an authoritative rebuilt location menu changes to a new externally published revision. Keep scheduled ETag-based reconciliation as a fallback.

Security checklist