Leal webhooks
Subscribe a URL to an event and Leal sends it a signed JSON POST the moment it happens: a customer joins, earns a stamp, unlocks a reward or redeems one. No polling, and every request can be verified with an off the shelf Standard Webhooks library.
Set up in three steps
-
1. Add an endpoint
In Leal, open Webhooks in your account settings, paste your URL and pick the events you want. The URL has to be public and use https. Choose All events to get everything, including events we add later, at one URL.
Or do the same with the API, using an API token and the id of your store. List the events you want, or pass ["*"] for every event.
curl -X POST https://app.tryleal.dev/api/v1/accounts/1/webhook_subscriptions \ -H "Authorization: Bearer $LEAL_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"events": ["reward.unlocked", "reward.redeemed"], "target_url": "https://example.com/hooks/leal"}' -
2. Keep the secret
Every endpoint has a signing secret starting whsec_. It is on the endpoint's page in Leal, and in the API response. Store it with your other credentials: you need it to check that a request really came from Leal. You can replace it at any time with Rotate secret.
{ "id": 12, "account_id": 1, "events": [ "reward.unlocked", "reward.redeemed" ], "event": null, "target_url": "https://example.com/hooks/leal", "description": null, "payload_format": "envelope", "enabled": true, "disabled_at": null, "disabled_reason": null, "last_delivery_at": null, "last_delivery_status": null, "last_delivery_error": null, "created_at": "2026-09-28T09:00:00.000Z", "updated_at": "2026-09-28T09:00:00.000Z", "secret": "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD" } -
3. Send yourself a test event
Click Send test event on the endpoint's page, or call the test endpoint. Leal posts a signed webhook.test event to your URL straight away and tells you what came back, so you can check your endpoint and your signature code without waiting for a customer.
curl -X POST https://app.tryleal.dev/api/v1/accounts/1/webhook_subscriptions/12/test \ -H "Authorization: Bearer $LEAL_API_TOKEN"{ "delivered": true, "status": 204, "event_id": "evt_2kXv8Rr5mQ1nH7cT4pW9yZ3b", "error": null }
Events
Every event belongs to one store. Each subscription lists the events it wants, as many as you like, or * for all of them. We may add events and add fields to existing payloads, so ignore what you do not recognise.
| Event | Sent when |
|---|---|
| customer.created | A customer joined the loyalty program, from a poster, the app, an import or the API. |
| customer.updated | A customer's name, email, phone, birthday, metadata or marketing and SMS consent changed. |
| customer_card.created | A customer was issued a loyalty card. Includes the links that add it to Apple Wallet and Google Wallet. |
| stamp.earned | Stamps were added to a customer's card: a scan in the app, a call to the API, a point of sale integration, a welcome bonus or a manual adjustment. |
| stamp.removed | Stamps were taken off a customer's card by an adjustment. |
| reward.unlocked | A customer now has enough stamps to redeem a reward. |
| reward.redeemed | A customer redeemed a reward and the stamps were deducted. |
customer.created
A customer joined the loyalty program, from a poster, the app, an import or the API.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "customer.created",
"timestamp": "2026-09-28T09:15:02.311Z",
"account_id": 1,
"data": {
"id": 42,
"account_id": 1,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"birthday": "1990-12-10",
"stamp_count": 0,
"metadata": {
"favourite_drink": "flat white"
},
"marketing_opted_out_at": null,
"sms_opted_out_at": null,
"external_references": [
{
"source": "square",
"external_id": "SQ-CUST-9981",
"metadata": {}
}
],
"created_at": "2026-09-28T09:15:02.311Z",
"updated_at": "2026-09-28T09:15:02.311Z"
}
}
customer.updated
A customer's name, email, phone, birthday, metadata or marketing and SMS consent changed.
previous_attributes holds the old value of each field that changed. A change to the stamp balance alone does not send this event: use the stamp events for that.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "customer.updated",
"timestamp": "2026-09-28T11:02:45.120Z",
"account_id": 1,
"data": {
"id": 42,
"account_id": 1,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"birthday": "1990-12-10",
"stamp_count": 6,
"metadata": {
"favourite_drink": "flat white"
},
"marketing_opted_out_at": "2026-09-28T11:02:45.120Z",
"sms_opted_out_at": null,
"external_references": [
{
"source": "square",
"external_id": "SQ-CUST-9981",
"metadata": {}
}
],
"created_at": "2026-09-28T09:15:02.311Z",
"updated_at": "2026-09-28T11:02:45.120Z"
},
"previous_attributes": {
"email": "[email protected]",
"marketing_opted_out_at": null
}
}
customer_card.created
A customer was issued a loyalty card. Includes the links that add it to Apple Wallet and Google Wallet.
Send the wallet links to the customer by email or SMS from your own system, or show them in your app.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "customer_card.created",
"timestamp": "2026-09-28T09:15:02.402Z",
"account_id": 1,
"data": {
"id": 311,
"account_id": 1,
"customer_id": 42,
"card_id": 3,
"card_name": "Coffee card",
"uuid": "k3v9qa",
"status": "active",
"stamps_count": 0,
"stamps_remaining": 6,
"apple_wallet_url": "https://app.tryleal.dev/w/a/eyJfcmFpbHMiOnsiZGF0YSI6MzExfX0--9c1e",
"google_wallet_url": "https://app.tryleal.dev/w/g/eyJfcmFpbHMiOnsiZGF0YSI6MzExfX0--4b7a",
"customer": {
"id": 42,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"stamp_count": 0
},
"issued_at": "2026-09-28T09:15:02.402Z",
"created_at": "2026-09-28T09:15:02.402Z"
}
}
stamp.earned
Stamps were added to a customer's card: a scan in the app, a call to the API, a point of sale integration, a welcome bonus or a manual adjustment.
entry_type says where the stamps came from: scan, api, initial (a welcome bonus) or adjust. balance_after is the card's new balance.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "stamp.earned",
"timestamp": "2026-09-28T12:40:18.007Z",
"account_id": 1,
"data": {
"id": 9051,
"account_id": 1,
"customer_id": 42,
"customer_card_id": 311,
"entry_type": "scan",
"delta": 1,
"balance_after": 6,
"notes": null,
"customer": {
"id": 42,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"stamp_count": 6
},
"created_at": "2026-09-28T12:40:18.007Z"
}
}
stamp.removed
Stamps were taken off a customer's card by an adjustment.
Redeeming a reward also lowers the balance, but sends reward.redeemed instead of this event.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "stamp.removed",
"timestamp": "2026-09-28T12:44:51.530Z",
"account_id": 1,
"data": {
"id": 9052,
"account_id": 1,
"customer_id": 42,
"customer_card_id": 311,
"entry_type": "adjust",
"delta": -1,
"balance_after": 5,
"notes": "Stamped twice by mistake",
"customer": {
"id": 42,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"stamp_count": 5
},
"created_at": "2026-09-28T12:44:51.530Z"
}
}
reward.unlocked
A customer now has enough stamps to redeem a reward.
Sent once per reward, at the moment the balance crosses its threshold, so a jump from 4 to 6 stamps unlocks a 5 stamp reward exactly once. Inactive rewards are skipped.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "reward.unlocked",
"timestamp": "2026-09-28T12:40:18.007Z",
"account_id": 1,
"data": {
"account_id": 1,
"customer_id": 42,
"customer_card_id": 311,
"card_id": 3,
"stamp_ledger_id": 9051,
"stamps_count": 6,
"reward": {
"id": 7,
"name": "Free coffee",
"description": "Any hot drink, any size",
"stamps_required": 6
},
"customer": {
"id": 42,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"stamp_count": 6
},
"unlocked_at": "2026-09-28T12:40:18.007Z"
}
}
reward.redeemed
A customer redeemed a reward and the stamps were deducted.
{
"id": "evt_7Qm2cT9xKpW4rB8nZs3fLd1a",
"type": "reward.redeemed",
"timestamp": "2026-09-29T08:05:33.914Z",
"account_id": 1,
"data": {
"id": 128,
"account_id": 1,
"customer_id": 42,
"customer_card_id": 311,
"reward_id": 7,
"stamps_spent": 6,
"redeemed_at": "2026-09-29T08:05:33.914Z",
"customer": {
"id": 42,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"stamp_count": 0
},
"reward": {
"id": 7,
"name": "Free coffee",
"description": "Any hot drink, any size",
"stamps_required": 6
},
"created_at": "2026-09-29T08:05:33.914Z"
}
}
The request
Each event is a POST with a JSON body and these headers:
POST /hooks/leal HTTP/1.1
Content-Type: application/json
User-Agent: Leal-Webhooks/1.0 (+https://www.tryleal.dev/developers/webhooks)
Leal-Event: reward.unlocked
webhook-id: evt_7Qm2cT9xKpW4rB8nZs3fLd1a
webhook-timestamp: 1790599218
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= The body
The body is an envelope around the object the event is about:
- id
- Unique id of the event, starting evt_. The same on every retry, and the same as the webhook-id header. Use it to ignore duplicates.
- type
- The event, for example reward.unlocked.
- timestamp
- When the event happened, in ISO 8601 UTC.
- account_id
- The store the event belongs to.
- data
- The object the event is about. Its shape depends on the event, see below.
- previous_attributes
- customer.updated only: the old values of the fields that changed.
Flat payloads and Zapier
Subscriptions made by the Zapier integration use "payload_format": "flat", which sends the data object on its own so Zaps can map its fields directly. You can ask for it too when you create a subscription with a single event. The event name is still in the Leal-Event header and the request is still signed. A customer.created event in flat format looks like this:
{
"id": 42,
"account_id": 1,
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+353861234567",
"birthday": "1990-12-10",
"stamp_count": 0,
"metadata": {
"favourite_drink": "flat white"
},
"marketing_opted_out_at": null,
"sms_opted_out_at": null,
"external_references": [
{
"source": "square",
"external_id": "SQ-CUST-9981",
"metadata": {}
}
],
"created_at": "2026-09-28T09:15:02.311Z",
"updated_at": "2026-09-28T09:15:02.311Z"
} Test events carry {"message":"This is a test event from Leal.","webhook_subscription_id":12} as their data.
Verifying signatures
Anyone who knows your URL can post to it, so check the signature before you trust a request. Leal signs every delivery with the Standard Webhooks scheme, which has ready made libraries for most languages. By hand, it is three steps:
- Join the webhook-id header, the webhook-timestamp header and the raw request body with full stops: id.timestamp.body.
- Compute an HMAC SHA-256 of that string. The key is your secret without the whsec_ prefix, base64 decoded. Base64 encode the result.
- webhook-signature is a space separated list of v1,<signature> values. Accept the request if one of them matches, using a constant time comparison, and reject it if the timestamp is more than five minutes from now.
Always verify the raw bytes you received. Parsing the JSON and serialising it again changes the body and the signature will not match.
Node.js
// npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.LEAL_WEBHOOK_SECRET); // "whsec_..."
const app = express();
// Verify against the raw body: parsing and re-serialising the JSON breaks the signature.
app.post("/hooks/leal", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body.toString(), req.headers);
} catch {
return res.status(400).send("Invalid signature");
}
if (event.type === "reward.unlocked") {
const { customer, reward } = event.data;
console.log(`${customer.first_name} can now redeem ${reward.name}`);
}
res.sendStatus(204);
});
app.listen(3000); Python
# pip install standardwebhooks flask
import os
from flask import Flask, abort, request
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["LEAL_WEBHOOK_SECRET"]) # "whsec_..."
app = Flask(__name__)
@app.post("/hooks/leal")
def leal_webhook():
try:
event = webhook.verify(request.get_data(), request.headers)
except Exception:
abort(400)
if event["type"] == "reward.unlocked":
customer, reward = event["data"]["customer"], event["data"]["reward"]
print(f"{customer['first_name']} can now redeem {reward['name']}")
return "", 204 Ruby
# No gem needed. In a Rails controller:
class LealWebhooksController < ActionController::API
def create
return head :bad_request unless valid_signature?
event = JSON.parse(request.raw_post)
case event["type"]
when "reward.unlocked"
customer, reward = event["data"].values_at("customer", "reward")
Rails.logger.info "#{customer["first_name"]} can now redeem #{reward["name"]}"
end
head :no_content
end
private
def valid_signature?
id = request.headers["webhook-id"]
timestamp = request.headers["webhook-timestamp"].to_i
return false if (Time.now.to_i - timestamp).abs > 300 # five minutes
key = Base64.decode64(ENV.fetch("LEAL_WEBHOOK_SECRET").delete_prefix("whsec_"))
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{request.raw_post}")
)
request.headers["webhook-signature"].to_s.split(" ").any? do |candidate|
version, signature = candidate.split(",", 2)
version == "v1" && ActiveSupport::SecurityUtils.secure_compare(signature.to_s, expected)
end
end
end Responding, retries and failures
- Reply with any 2xx status within 10 seconds. Do slow work after you respond, in a queue.
- Anything else is retried: other status codes, timeouts, connection errors and redirects, which are not followed. Leal makes up to 10 attempts with growing gaps, over about four hours.
- A retry has the same webhook-id and body, so store the ids you have handled and skip repeats. Events can also arrive out of order: use timestamp or the balances in the payload rather than the order of arrival.
- Reply 410 Gone and Leal deletes the subscription.
- If no delivery to a subscription has succeeded for three days, Leal switches it off: enabled becomes false and disabled_reason is failing. Fix the endpoint, then click Re-enable on its page in Leal, or send PATCH with {"enabled":true}.
- The endpoint's page in Leal shows the last delivery, and so does the API: every subscription has last_delivery_at, last_delivery_status and last_delivery_error, so you can see what your endpoint last returned.
Managing subscriptions
All paths are relative to https://app.tryleal.dev/api/v1 and take the same Authorization: Bearer token as the rest of the API. Parameters can go at the top level of the JSON body or under webhook_subscription. Older integrations that send a single event string still work: it is the same as an events list with one entry. The API reference and the OpenAPI description have every field.
| Method | Path | What it does |
|---|---|---|
| GET | /accounts/:account_id/webhook_subscriptions | List subscriptions. Filter with ?event=. |
| POST | /accounts/:account_id/webhook_subscriptions | Subscribe a URL to one or more events. Returns the signing secret. |
| GET | /accounts/:account_id/webhook_subscriptions/:id | One subscription, with its secret and the result of the last delivery. |
| PATCH | /accounts/:account_id/webhook_subscriptions/:id | Change the URL, event or label, or pause and resume with enabled. |
| DELETE | /accounts/:account_id/webhook_subscriptions/:id | Stop deliveries and delete the subscription. |
| POST | /accounts/:account_id/webhook_subscriptions/:id/test | Send a signed webhook.test event now and report what your URL returned. |
| POST | /accounts/:account_id/webhook_subscriptions/:id/rotate_secret | Replace the signing secret. |
Good to know
- Payloads contain customer names, emails and phone numbers. Only subscribe URLs you control, over https.
- Leal refuses URLs that point at private or internal networks.
- Subscriptions belong to a store. If you manage several stores, create a subscription for each and use account_id to tell them apart.
- Rather not write code? The Zapier integration uses these same webhooks to trigger Zaps.
Need an event that is not here? Tell us.