Webhooks
Webhooks tell your server when something changes in the loyalty program, so you don't have to poll. Each one is a signed HTTPS POST that you verify, acknowledge and then process.
Available at launch
What webhooks are for#
Use them for anything your systems need to react to without a guest in front of the screen:
- Refresh a cached balance when
points.changedarrives, including points earned at the register. - Send your own "You reached Gold" email on
tier.changed. - Sync opt-ins to your email or SMS provider on
communication_preferences.updated. - Delete your copy of a guest's data on
member.deleted.
Webhooks complement the API; they don't replace it. When you need the current state of something, read it from the API. The full list of event types and their payloads is on Webhook event types.
How delivery works#
- Something changes. unPLUG writes an event with a UUID, a type such as
order.creditedand a snapshot of the resource. - unPLUG sends it. Every enabled endpoint subscribed to that type gets a POST with a JSON body and three headers:
webhook-id,webhook-timestampandwebhook-signature. The format follows Standard Webhooks. - Your server verifies and acknowledges. Check the signature, store the event, return a 2xx within 10 seconds.
- Your worker processes it. Outside the request, at its own pace.
Anything other than a 2xx within 10 seconds counts as a failure and is retried. Redirects (3xx) count as failures too, so register the final URL.
Set up an endpoint#
You need a secret key with the webhooks:manage scope. Each environment (sandbox and production) has its own endpoints, up to 10 each. URLs must use HTTPS and a public hostname.
Create the endpoint with the API:
curl -X POST https://sandbox.api.unplugdining.com/v1/webhook_endpoints \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/unplug",
"event_types": ["points.changed", "tier.changed", "reward.issued", "member.deleted"],
"description": "Loyalty sync"
}'const response = await fetch("https://sandbox.api.unplugdining.com/v1/webhook_endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://api.example.com/webhooks/unplug",
event_types: ["points.changed", "tier.changed", "reward.issued", "member.deleted"],
description: "Loyalty sync",
}),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://sandbox.api.unplugdining.com/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
},
json={
"url": "https://api.example.com/webhooks/unplug",
"event_types": [
"points.changed",
"tier.changed",
"reward.issued",
"member.deleted",
],
"description": "Loyalty sync",
},
)
data = response.json(){
"id": "1c7f4a9e-3d2b-4f86-a5e1-8b0d6c3f9e72",
"url": "https://api.example.com/webhooks/unplug",
"description": "Loyalty sync",
"event_types": ["points.changed", "tier.changed", "reward.issued", "member.deleted"],
"status": "enabled",
"secret": "whsec_4cWtG7hYe5kSMHqAFDTiyxca6HNalvlQ",
"created_at": "2026-11-02T14:51:30Z",
"updated_at": "2026-11-02T14:51:30Z"
}Store secret in your secrets manager now. The API shows it once and never returns it again. Use ["*"] for event_types to get every type, including types added later.
If you'd rather not call the API, email support@unplugdining.com with the URL and the event types you want. Support creates the endpoint and sends you the signing secret through a secure link. Managing endpoints in the developer dashboard is Coming soon.
Verify the signature#
Anyone can POST to your URL. The signature proves the request came from unPLUG and wasn't changed. Reject anything that doesn't verify.
A delivery looks like this:
POST /webhooks/unplug HTTP/1.1
Host: api.example.com
Content-Type: application/json
webhook-id: 7f3a9c2e-4b8d-4e16-a0c5-1d6b8e2f9a47
webhook-timestamp: 1793887732
webhook-signature: v1,h040RAsmDKEcqk4Lh13vuOrVBK9yBovp0Tduidp9DrE=
{"id":"7f3a9c2e-4b8d-4e16-a0c5-1d6b8e2f9a47","type":"points.changed",...}- Read the raw body. Use the exact bytes you received. If your framework parses JSON first and you serialize it again, spacing and key order can change and the signature won't match.
- Check the timestamp.
webhook-timestampis Unix seconds. Reject the request if it's more than 5 minutes from your clock. This stops someone replaying an old request. Keep your server clock synced with NTP. - Build the key. Strip the
whsec_prefix from your secret and base64-decode the rest. The decoded bytes are the HMAC key. - Build the signed content.
{webhook-id}.{webhook-timestamp}.{raw body}, joined with periods. - Compute the signature. HMAC-SHA256 of the signed content with the key, then base64-encode it.
- Compare.
webhook-signatureis a space-separated list ofv1,<signature>entries. It has more than one entry while a secret rotation is in progress. Accept the request if anyv1entry matches. Compare in constant time.
Node 18 or later, with Express. express.raw keeps the body as a Buffer.
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhook(rawBody, headers, secret, now = Math.floor(Date.now() / 1000)) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) {
throw new Error("missing webhook headers");
}
// 1. Reject old or future timestamps (replay protection).
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) {
throw new Error("timestamp outside tolerance");
}
// 2. The key is the part after "whsec_", base64-decoded.
if (!secret.startsWith("whsec_")) throw new Error("secret must start with whsec_");
const key = Buffer.from(secret.slice("whsec_".length), "base64");
// 3. Sign "{webhook-id}.{webhook-timestamp}.{raw body}".
const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const expected = crypto.createHmac("sha256", key).update(signedContent).digest();
// 4. The header can hold several "v1,<base64>" entries. Any match passes.
for (const entry of signatureHeader.split(" ")) {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) continue;
const given = Buffer.from(signature, "base64");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return JSON.parse(rawBody.toString("utf8"));
}
}
throw new Error("no matching signature");
}
module.exports = { verifyWebhook };const express = require("express");
const { verifyWebhook } = require("./verify");
const SECRET = process.env.UNPLUG_WEBHOOK_SECRET; // whsec_...
const app = express();
// Register this route before any app.use(express.json()),
// or the body is parsed and the raw bytes are gone.
app.post("/webhooks/unplug", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, SECRET);
} catch (err) {
return res.status(400).send("invalid signature");
}
res.sendStatus(200); // acknowledge first
enqueue(event, req.headers["webhook-id"]); // your job queue does the work
});
app.use(express.json()); // the rest of your API
app.listen(3000);Python 3.10 or later, standard library only. The route uses Flask; any framework works as long as you pass the raw bytes.
import base64
import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 5 * 60
class InvalidWebhook(Exception):
pass
def verify_webhook(raw_body: bytes, headers, secret: str, now: float | None = None) -> dict:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header):
raise InvalidWebhook("missing webhook headers")
# 1. Reject old or future timestamps (replay protection).
try:
ts = int(timestamp)
except ValueError:
raise InvalidWebhook("bad timestamp")
now = time.time() if now is None else now
if abs(now - ts) > TOLERANCE_SECONDS:
raise InvalidWebhook("timestamp outside tolerance")
# 2. The key is the part after "whsec_", base64-decoded.
if not secret.startswith("whsec_"):
raise InvalidWebhook("secret must start with whsec_")
key = base64.b64decode(secret[len("whsec_"):])
# 3. Sign "{webhook-id}.{webhook-timestamp}.{raw body}".
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
# 4. The header can hold several "v1,<base64>" entries. Any match passes.
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return json.loads(raw_body)
raise InvalidWebhook("no matching signature")import os
from flask import Flask, request
from verify import InvalidWebhook, verify_webhook
app = Flask(__name__)
SECRET = os.environ["UNPLUG_WEBHOOK_SECRET"] # whsec_...
@app.post("/webhooks/unplug")
def unplug_webhook():
raw_body = request.get_data() # bytes, exactly as sent
try:
event = verify_webhook(raw_body, request.headers, SECRET)
except InvalidWebhook:
return "invalid signature", 400
enqueue(event, request.headers["webhook-id"]) # your job queue does the work
return "", 200Test your code with a known signature#
Run your verifier against these values before you point it at real traffic. Pass a fixed "now" (both functions above take one) so the 5-minute check doesn't reject the old timestamp.
| Input | Value |
|---|---|
| Secret | whsec_4cWtG7hYe5kSMHqAFDTiyxca6HNalvlQ |
webhook-id | 7f3a9c2e-4b8d-4e16-a0c5-1d6b8e2f9a47 |
webhook-timestamp | 1793887732 (2026-11-05T14:08:52Z) |
Expected webhook-signature | v1,h040RAsmDKEcqk4Lh13vuOrVBK9yBovp0Tduidp9DrE= |
The body is this exact single line, with no trailing newline:
{"id":"7f3a9c2e-4b8d-4e16-a0c5-1d6b8e2f9a47","type":"points.changed","created_at":"2026-11-05T14:08:52Z","api_version":"v1","livemode":false,"brand_id":"e5a1c7d9-2f4b-4c8e-a3d6-8b0f1e9c2a57","data":{"object":{"id":"b3e7c1f9-8a2d-4b6e-9c05-2f4a7d1e8b36","member_id":"3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40","type":"earn","points":90,"balance_after":510,"description":"Order app-30417","order_id":"c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24","reward_id":null,"adjustment_reason":null,"created_at":"2026-11-05T14:08:52Z"},"previous_attributes":{}}}
Change one character of the body and verification must fail.
Use a library instead#
unPLUG follows the Standard Webhooks spec, so any Standard Webhooks library verifies our requests. The standardwebhooks packages on npm and PyPI accept the full whsec_ secret and apply the same 5-minute window. Pass them the raw body, as above.
Respond fast, process later#
Return a 2xx as soon as the event is verified and stored. Do the real work (database writes, emails, calls to other services) in a background job. If your handler takes more than 10 seconds, unPLUG records a failure and sends the event again, even if your code finished.
Return a 4xx only when the signature fails. Any non-2xx response is retried, so returning 500 for an event type you don't handle wastes retries for 3 days. Return 200 and ignore it.
Retries#
A failed delivery is retried on this schedule. Each attempt to each endpoint is separate: one endpoint failing doesn't hold up the others.
| Attempt | Wait after the previous attempt | Time since the first attempt |
|---|---|---|
| 1 | None | 0 |
| 2 | 5 seconds | 5 seconds |
| 3 | 5 minutes | about 5 minutes |
| 4 | 30 minutes | about 35 minutes |
| 5 | 2 hours | about 2.5 hours |
| 6 | 5 hours | about 7.5 hours |
| 7 | 10 hours | about 17.5 hours |
| 8 | 10 hours | about 27.5 hours |
| 9 and later | 12 hours | until 3 days have passed |
Each retry carries the same webhook-id and body, with a new webhook-timestamp and signature.
Automatic disabling#
If every delivery to an endpoint fails for 3 days, unPLUG sets the endpoint's status to disabled, stops sending to it and emails your technical contact. Fix the problem, turn the endpoint back on with PATCH /v1/webhook_endpoints/{id} and "status": "enabled", then redeliver what you missed from the event log.
Warning The event log keeps 30 days. Events older than that can't be redelivered, so don't leave a disabled endpoint for weeks.
Handle duplicates#
Delivery is at least once. You can get the same event twice: a retry after a timeout, a manual redelivery, a network hiccup. Use webhook-id (the same value as the event id) as an idempotency key and skip events you've already processed.
CREATE TABLE unplug_webhook_events (
webhook_id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
-- In the worker: no row returned means you already handled this event.
INSERT INTO unplug_webhook_events (webhook_id, type)
VALUES ($1, $2)
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id;Keep processed ids for at least 4 days, longer than the 3-day retry window. Keep them 30 days if you redeliver from the event log.
Ordering#
Events can arrive out of order. A retried points.changed from 14:08 can land after one from 14:30. Don't apply events as a sequence of changes. Instead:
- Compare
created_atwith what you have stored and ignore older snapshots. For points,balance_afteron the newest entry is the balance. - When the exact current state matters, treat the event as a signal and read the resource from the API, for example
GET /v1/members/{id}.
A member.deleted can arrive before an older member.updated for the same member. Keep a note of deleted member ids so a late update doesn't recreate the guest in your system.
Rotate the signing secret#
Rotate when someone who had the secret leaves, when it may have leaked, or on your regular schedule. Rotation has no downtime:
- Ask for a new secret. Call
POST /v1/webhook_endpoints/{id}/rotate_secretwith"expire_current_in_hours": 24. The response holds the new secret. - Deploy it. Until the old secret expires, every request is signed with both, so
webhook-signatureholds twov1entries. Your verifier passes with either secret, so deploy at any point in the window. - Let the old one expire. After 24 hours only the new secret signs.
curl -X POST https://api.unplugdining.com/v1/webhook_endpoints/1c7f4a9e-3d2b-4f86-a5e1-8b0d6c3f9e72/rotate_secret \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-d '{ "expire_current_in_hours": 24 }'const response = await fetch("https://api.unplugdining.com/v1/webhook_endpoints/1c7f4a9e-3d2b-4f86-a5e1-8b0d6c3f9e72/rotate_secret", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ expire_current_in_hours: 24 }),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/webhook_endpoints/1c7f4a9e-3d2b-4f86-a5e1-8b0d6c3f9e72/rotate_secret",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
},
json={"expire_current_in_hours": 24},
)
data = response.json()If the secret leaked, pass 0 so the old secret stops signing right away. Expect failed deliveries until you deploy the new one; they're retried.
Send test events#
POST /v1/webhook_endpoints/{id}/test sends a webhook.test event to that one endpoint. Use it to check that your URL is reachable and your signature code works, in either environment.
In the sandbox you can also fire any event type with sample data, or create real events:
POST /v1/test/webhookswith{ "type": "tier.changed" }sends a sample event of that type to your subscribed endpoints.POST /v1/test/in_store_orderssimulates a register visit, which firesorder.creditedwith"channel": "in_store"andpoints.changed.
Event log and redelivery#
Every event stays in the log for 30 days, with each delivery attempt to each endpoint.
GET /v1/events?type=order.creditedlists events, newest first.GET /v1/events/{id}/deliveriesshows each attempt: status code, response time, error and the next retry.POST /v1/events/{id}/redeliversends the event again, with the samewebhook-id.
After an outage, list the events from the outage window and redeliver them. Your dedupe table skips any you already processed.
Test locally#
unPLUG can only reach public HTTPS URLs. To receive sandbox events on your laptop, run a tunnel that gives your local port a public HTTPS address, such as ngrok or Cloudflare Tunnel:
ngrok http 3000
# Forwarding https://5c1e-203-0-113-24.ngrok-free.app -> http://localhost:3000Create a sandbox endpoint with that URL plus your path (https://5c1e-203-0-113-24.ngrok-free.app/webhooks/unplug), then call the test endpoint. Most tunnels change their URL on restart; update the endpoint with PATCH when that happens, and delete it when you're done.
Note Use tunnels with sandbox keys only. Production endpoints belong on your own servers.
Before you go live#
- Your production endpoint uses HTTPS on a public hostname and returns 2xx within 10 seconds.
- The signing secret lives in a secrets manager, not in source code.
- Your handler verifies the signature on the raw body, checks the 5-minute window and compares in constant time.
- It accepts any matching entry when
webhook-signatureholds two signatures. - Work happens in a background job, not in the request.
- You dedupe on
webhook-id. - You don't assume order: you compare
created_ator re-fetch. - Unknown event types get a 200 and are ignored, so new types don't break you.
- Someone reads the email address on file for disabled endpoints.
- You've run a
webhook.testagainst the production endpoint.