Receiving webhooks
Create a subscription with POST /merchants/me/webhooks, pointing at an HTTPS endpoint you control. Stow Cards POSTs one JSON envelope per event, with these headers:
| Header | Value |
|---|
X-Stow-Webhook-Event | the event type, e.g. points.changed |
X-Stow-Webhook-Timestamp | ISO-8601 timestamp of the delivery attempt |
X-Stow-Webhook-Subscription | the subscription id |
X-Stow-Webhook-Signature | sha256= + HMAC-SHA256 signature (present when the subscription has a secret) |
User-Agent | stow-cards-webhook/1.0 |
Respond with any 2xx within the subscription's timeoutMs. Non-2xx or timeouts are retried up to retryPolicy.maxAttempts with backoffMs between attempts. Deliveries (payload, status, attempts) are inspectable at GET /merchants/me/webhooks/delivery-logs, and any logged delivery can be re-sent with POST /merchants/me/webhooks/delivery-logs/{logId}/replay.
Idempotency on your side: deliveries are at-least-once. Dedupe on eventId: replays and retries reuse it.
Verifying the signature
The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encoded, prefixed with sha256=. The {timestamp} is the value of the X-Stow-Webhook-Timestamp header. Checking it also protects you from replays (reject deliveries older than, say, 5 minutes).
Node.js
import crypto from 'node:crypto';
function verifyStowWebhook(req, secret, toleranceMs = 5 * 60 * 1000) {
const timestamp = req.headers['x-stow-webhook-timestamp'];
const received = req.headers['x-stow-webhook-signature'] || '';
if (!timestamp || !received.startsWith('sha256=')) return false;
if (Math.abs(Date.now() - Date.parse(timestamp)) > toleranceMs) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`) // rawBody: the exact bytes received
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
Python
import hashlib, hmac, time
from datetime import datetime, timezone
def verify_stow_webhook(headers, raw_body: bytes, secret: str, tolerance_s=300):
timestamp = headers.get("X-Stow-Webhook-Timestamp", "")
received = headers.get("X-Stow-Webhook-Signature", "")
if not timestamp or not received.startswith("sha256="):
return False
sent = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if abs(time.time() - sent.timestamp()) > tolerance_s:
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(received, expected)
Compute the HMAC over the raw request bytes, not a re-serialized JSON object, because key order matters.
Choosing events
Subscribe to specific events or * for everything. Test any subscription with POST /merchants/me/webhooks/{id}/test. It queues a real delivery of a test: true payload using the subscription's first event type.