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:
- OAuth client credentials
- Applications and scopes
- BevQ API example
- Rate limits and HTTP errors
- Customer API Terms
Supported events
| Event type | Purpose | Trigger | Module requirement |
|---|---|---|---|
bevq.menu.updated | Production change notification | A 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.test | Safe management test | A 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:
- Use the Webhook Signing Secret only to authenticate webhook requests.
- Use the OAuth Client ID + Client Secret only at
POST https://api.smartq.tv/oauth/token. - Use the resulting 24-hour OAuth access token only to retrieve Customer API resources.
- Never use the OAuth Client Secret to verify a webhook.
- Never send the Webhook Signing Secret to the OAuth token endpoint.
Destination management
In SmartQ Admin, open Settings -> API Access -> Customer API Webhooks.
- Select Add destination.
- Choose an eligible BevQ-enabled application.
- Enter a descriptive name and a public HTTPS destination URL.
- Choose All authorized locations or Selected authorized locations when the application is group-scoped.
- Leave Enabled selected and save the destination.
- Copy the one-time Webhook Signing Secret directly into the receiver's server-side secret manager.
- 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:
- Edit: change the destination name, public HTTPS URL, eligible parent application, location coverage, or enabled state. Leaving the URL blank while editing keeps the current URL. Editing preserves the current signing secret.
- Rotate secret: generate a new one-time secret. Rotation uses
CURRENT_ONLY_NO_OVERLAP; the prior secret becomes invalid immediately and there is no dual-secret grace period. - Send test: queue a signed
customer_api.webhook.testevent through the same delivery worker and retry policy as production events. - Remove: delete the destination, subscription, and stored secret. SmartQ then stops transmitting to it.
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:
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json; charset=utf-8 | JSON body encoded as UTF-8 |
User-Agent | SmartQ-Customer-API-Webhooks/1.0 | SmartQ Customer API webhook sender |
X-SmartQ-Event | bevq.menu.updated | Event type; test deliveries use customer_api.webhook.test |
X-SmartQ-Event-Id | 8c34...d970 | Stable event identifier used for idempotency |
X-SmartQ-Timestamp | 1788480000 | Unix time in seconds as a decimal string, generated for this delivery attempt |
X-SmartQ-Signature | sha256=4f1a...9c20 | HMAC-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
| Field | Type | Required | Meaning | Example |
|---|---|---|---|---|
eventId | string | Yes | Stable logical event ID. Production menu events derive it from group, location, and revision; test events receive a synthetic ID. | 8c34...d970 |
eventType | string | Yes | bevq.menu.updated for production or customer_api.webhook.test for a safe test. | bevq.menu.updated |
occurredAt | string | Yes | ISO 8601 timestamp for event creation. | 2026-09-04T04:00:00.000Z |
groupId | string | Yes | SmartQ location-group identifier. | group_demo |
locationId | string | Yes | Affected authorized location. | location_demo |
revision | string | Yes | Current 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 |
resource | string | Yes | Customer API path to retrieve the affected menu. | /v1/group_demo/location_demo/bevq/menu |
test | boolean | No | Present 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:
| Property | Contract |
|---|---|
| Algorithm | HMAC-SHA256 |
| Secret | The destination's independently generated Webhook Signing Secret |
| Signed input | X-SmartQ-Timestamp + "." + rawRequestBody |
| Character encoding | UTF-8 |
| Digest encoding | Lowercase hexadecimal |
| Header prefix | sha256= |
| Final value | sha256=<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
- Express: register
express.raw({ type: "application/json" })for the webhook route before anyexpress.json()middleware, or use the JSON parser'sverifycallback to retain the originalBuffer. - Next.js: in an App Router route handler, read
await request.arrayBuffer()once and verify those bytes beforeJSON.parse. In Pages Router, disable the default body parser for the route and read the incoming stream. - Firebase/Google Cloud Functions: use
req.rawBodywhen available; it is the platform-providedBufferfor the exact request body. Verify before usingreq.body. - Flask: use
request.get_data(cache=True, as_text=False)before JSON parsing. - FastAPI/Starlette: use
await request.body()before validation or model binding. - PHP/WordPress: read
php://inputbefore callingjson_decode; do not rebuild the body from$_POSTor parsed JSON.
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:
- Capture the raw body and headers.
- Validate the timestamp format and your recommended replay window.
- Verify the HMAC signature with a constant-time comparison.
- Check
X-SmartQ-Event-Idin durable storage. - If it is already processed, return a 2xx response without repeating side effects.
- Parse and process the event.
- Record the event ID durably.
- Return a 2xx response.
Retrieve the authoritative menu
For bevq.menu.updated:
- Verify the webhook and deduplicate the event.
- Confirm that
eventType,groupId,locationId, andresourcematch the integration's expected SmartQ scope. Do not treatresourceas an arbitrary external URL. - Reuse a valid OAuth access token or exchange the application's Client ID and Client Secret at
/oauth/token. - Call
https://api.smartq.tvplus the suppliedresourcepath from your trusted backend. Send the last stored ETag withIf-None-Matchwhen available. - On
200, replace the local projection and store the new ETag/revision. On304, keep the current projection. - 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:
- network failures and timeouts;
- HTTP
408 Request Timeout; - HTTP
425 Too Early; - HTTP
429 Too Many Requests; - every HTTP
5xxresponse.
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
- Disabled destination: no new matching events are dispatched. A queued attempt re-checks the destination and is marked failed without an outbound request.
- Removed destination: the destination, subscription, and secret are deleted. A queued attempt finds missing configuration and is marked failed without an outbound request.
- Revoked/inactive or no-longer-BevQ parent application: delivery eligibility is re-checked before transmission. The attempt is marked failed without an outbound request. Re-enable by using an active eligible application; never assume a revoked application's webhook remains valid.
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:
- destination state:
NOT_TESTED,HEALTHY,FAILING, orDISABLED; - safe destination hostname;
- last success and last failure times;
- up to three recent deliveries per destination in the current view, including delivery status and a safe error code.
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
- Verify against the exact raw bytes received, before parsing or reserialization.
- Use the exact
X-SmartQ-Timestampstring from the same request. - Include the literal period between timestamp and body.
- Confirm lowercase hexadecimal output and the
sha256=prefix. - Confirm the destination's current Webhook Signing Secret, not its OAuth Client Secret.
- If the secret was rotated, the previous secret is already invalid.
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
- Accept webhooks only over HTTPS.
- Keep the Webhook Signing Secret, OAuth Client Secret, and access tokens in server-side secret storage.
- Never log full secrets, signatures paired with raw sensitive payloads, access tokens, or Authorization headers.
- Verify timestamp and signature before parsing or processing.
- Use a timing-safe comparison function.
- Enforce a reasonable receiver-side replay window, such as five minutes.
- Deduplicate with
X-SmartQ-Event-Id. - Validate the expected event type, group, location, and resource path after authentication.
- Rotate a compromised Webhook Signing Secret immediately and update the receiver atomically; there is no old-secret overlap.
- Return generic external errors without diagnostic secrets.
- Keep scheduled conditional reconciliation even when webhook delivery is healthy.