JS SDK
Headless JS API for building custom loyalty UIs on top of Adverfly's backend logic
The Adverfly tracking pixel exposes a window.adverfly.loyalty SDK so you can build your own loyalty UI (product badges, balance dashboards, cart blocks) while we handle the backend logic — rate computation, balance lookup, redemption, fraud, expiry.
Use the SDK when the pre-built Liquid snippets don't fit your design. The same backend powers both.
Quick start
<!-- 1. Load the Adverfly pixel (you probably have this already) -->
<script src="https://cdn.adverfly.com/v4/preset-pixel-adv.js" async></script>
<script>
window.adverfly = window.adverfly || [];
advPxl("init", 188334); /* your workspace id */
</script>
<!-- 2. (Optional) Install the Session Token block in theme.liquid so
the customer-scoped calls (getBalance, updateProfile) work — see "Auth" below. -->
<!-- 3. Use the SDK -->
<div id="cashback-amount"></div>
<script>
document.addEventListener("adverfly:loyalty:ready", function () {
window.adverfly.loyalty
.computeCashback(4990, "shopify_product_123", ["winter-sale"])
.then(function (res) {
document.getElementById("cashback-amount").textContent =
"Earn " + (res.amount_minor / 100).toFixed(2) + " € credit";
});
});
</script>
That's it. The SDK pre-fetches the program snapshot once per session and serves all subsequent calls from cache.
Auth model
The SDK has two tiers — pick the right one per call.
Tier 1 — Anonymous-safe
These methods need no auth. The snapshot they read from is public on our CDN (your earn rules, rewards catalog, branding).
| Method | Returns |
|---|---|
getProgram() | The full program snapshot (program + tiers + rewards + earn_rules) |
getCashbackRate(productId, [categoryIds]) | number — % rate for this product |
computeCashback(priceMinor, productId, [categoryIds]) | { amount_minor, rate_pct } |
requestBalanceEmail(email) | { ok: true } — fires the magic-link mail; nothing returned to client |
Tier 2 — Token required
These methods read PII and require a session token signed server-side by your loyalty api_key.
| Method | Returns |
|---|---|
getBalance() | { balance, coupon_code, tier_name, currency, … } |
updateProfile(props) | { properties } — merged JSONB after the update |
If the token is missing or expired, the call rejects with { code: "auth_required" }.
No in-widget redeem flow. Customers don't trade balance for a fresh code per redemption — they have one persistent code (returned in getBalance().coupon_code) that they paste at Shopify checkout. The discount value tracks their live balance via the webhooks/loyalty/order webhook + 15-min sync backup. Build your storefront UI around displaying that code, not around a "rewards catalog".
updateProfile — what you can write
Any key you want, with three exceptions:
- Server-owned (blocked):
last_birthday_credited_year,granted_tier_unlocks,after_earn,monthly_summary,subscribed_at,unsubscribed_at. Trying to write these returns400 Invalid field. - Validated:
birthdaymust beYYYY-MM-DD(the birthday cron parses it as a date). Other keys are pass-through. - Shape: values must be
string,number,boolean, ornull(to clear). Arrays / nested objects rejected. Max 32 keys per request, max 1024 chars per value.
window.adverfly.loyalty.updateProfile({ birthday: "1990-05-15", locale: "de" });
window.adverfly.loyalty.updateProfile({ shoe_size: 42, favorite_color: "blue" });
window.adverfly.loyalty.updateProfile({ birthday: null }); // clear
Custom fields stay in the same customers.properties JSONB — accessible to your own webhook handlers and (later) segmentation rules.
Installing the Session Token block (Shopify)
-
One-time setup: in your Shopify admin → Settings → Custom data → Shop → add a metafield
adverfly.api_key(single-line text). Paste your loyalty api_key from the Adverfly dashboard. -
Paste this block into
layout/theme.liquid, before the SDK is used:
{%- if customer -%}
{%- assign adv_api_key = shop.metafields.adverfly.api_key -%}
{%- assign adv_exp_ts = "now" | date: "%s" | plus: 3600 -%}
{%- assign adv_email = customer.email | downcase -%}
{%- assign adv_payload = adv_email | append: ":" | append: adv_exp_ts -%}
{%- assign adv_sig = adv_payload | hmac_sha256: adv_api_key -%}
<script>
window.adverfly = window.adverfly || [];
window.adverfly.loyalty_session_token = {{ adv_payload | append: ":" | append: adv_sig | json }};
window.adverfly.customer_id = {{ adv_email | json }};
</script>
{%- endif -%}
The merchant's api_key stays on the server — only the signed token reaches the browser. Tokens expire after 1 hour; every page render mints a fresh one.
Non-Shopify storefronts
Generate the same email:exp_ts:hex_hmac_sha256 token server-side in whatever stack you use (WooCommerce hook, custom Node app, edge function). Assign it to window.adverfly.loyalty_session_token before any Tier-2 call.
Events
The SDK dispatches events on document so DOM listeners can react without polling.
| Event | When | event.detail |
|---|---|---|
adverfly:loyalty:ready | Snapshot loaded (fires once per page-load, after first getProgram / getCashbackRate / computeCashback resolves) | { snapshot } |
adverfly:loyalty:balance | After any successful getBalance() call | { balance, tier_name, currency, … } |
document.addEventListener("adverfly:loyalty:balance", function (e) {
document.querySelector(".my-balance-pill").textContent =
e.detail.balance.toFixed(2) + " €";
});
Recipes
Custom product badge
<span class="my-cashback" data-product-id="{{ product.id }}" data-price="{{ product.price }}"></span>
<script>
document.addEventListener("adverfly:loyalty:ready", function () {
document.querySelectorAll(".my-cashback").forEach(function (el) {
var productId = el.dataset.productId;
var price = Number(el.dataset.price);
window.adverfly.loyalty.computeCashback(price, productId).then(function (res) {
if (res.rate_pct === 0) return; /* hide badge for excluded products */
el.textContent = "+" + (res.amount_minor / 100).toFixed(2) + " € credit";
});
});
});
</script>
Custom balance display
<div class="my-loyalty-balance">…</div>
<script>
window.adverfly.loyalty.getBalance()
.then(function (b) {
document.querySelector(".my-loyalty-balance").textContent =
b.balance.toFixed(2) + " " + b.currency;
})
.catch(function (err) {
if (err.code === "auth_required") {
/* Customer isn't logged in — show "Sign in to view balance" */
}
});
</script>
Birthday picker on a custom account page
<input id="birthday" type="date" />
<button id="save-birthday">Save</button>
<script>
document.getElementById("save-birthday").addEventListener("click", function () {
var bday = document.getElementById("birthday").value; // already YYYY-MM-DD
window.adverfly.loyalty.updateProfile({ birthday: bday })
.then(function () { alert("Saved!"); })
.catch(function (err) { alert("Couldn't save: " + (err.message || err.code)); });
});
</script>
Custom coupon-code display
The customer's persistent coupon code is on getBalance().coupon_code. Show it on the balance card / cart drawer — they paste it at Shopify checkout to redeem any portion of their balance.
<div class="my-loyalty-code">
<span class="my-balance">…</span>
<code class="my-code">…</code>
<button class="my-copy">Copy</button>
</div>
<script>
window.adverfly.loyalty.getBalance().then(function (b) {
document.querySelector(".my-balance").textContent =
b.balance.toFixed(2) + " " + b.currency;
document.querySelector(".my-code").textContent = b.coupon_code;
document.querySelector(".my-copy").addEventListener("click", function () {
navigator.clipboard.writeText(b.coupon_code);
});
});
</script>
Errors
All Tier-2 methods reject with a discriminated object you can switch on:
window.adverfly.loyalty.getBalance().catch(function (err) {
switch (err.code) {
case "auth_required": /* no session token */ break;
case "auth_failed": /* token expired or wrong sig */ break;
case "no_workspace_id_or_email": /* SDK not initialised yet */ break;
case "http_error": /* network / server */ break;
}
});
Snapshot freshness
The program snapshot on CDN updates whenever a merchant saves changes in the Adverfly dashboard (earn rules, tiers, rewards). CloudFront serves stale-while-revalidate for 60 seconds — most edits propagate in under a minute.
If you need an instant refresh, force a reload by clearing the in-memory cache:
delete window.__advLoyaltySnapshot;
window.adverfly.loyalty.getProgram(); /* triggers a fresh fetch */
Webhooks
Two server-to-server endpoints power the parts of the system that don't sit behind the browser SDK. Both are flat single-segment URLs under https://api.adverfly.com/loyalty/v1/.
Custom Earn — POST /webhooks/loyalty/earn
Fires a non-purchase earn rule (signup bonus, review reward, birthday, custom). Each rule has its own webhook_token exposed in the dashboard's Custom Earn tab.
curl -X POST "https://api.adverfly.com/webhooks/loyalty/earn?id=<RULE_TOKEN>&email=customer@example.com" \
-H "X-Adverfly-Key: <PROGRAM_API_KEY>"
| Field | Source | Notes |
|---|---|---|
id (query) | Earn rule's webhook_token | One token per rule. Rotate by deleting + recreating the rule. |
email (query) | Customer email | Customer identity + default idempotency_key. |
X-Adverfly-Key (header) | Program api_key | Constant-time match. Fallback ?key= for clients that can't send headers. |
idempotency_key (query, optional) | External event id | When omitted, defaults to email so the same customer can't be credited twice. |
GET works too — pasteable into webhook fields that ignore HTTP methods. Rate-limit per rule is configurable (unbegrenzt / einmalig / monatlich / halbjährl. / jährlich); dedup via the webhook_log audit table.
Shopify Order — POST /webhooks/loyalty/order
Instant coupon_use detection. Shopify sends one POST per created order; the Lambda parses discount_codes[] and discount_applications[], matches against the customer's persistent coupon code, debits the balance via loyalty_transactions with reference_type='coupon_use', and PUTs the Shopify code value down to the new balance.
Merchant setup (one-time, in Shopify Admin):
- Settings → Notifications → Webhooks → Create webhook
- Event:
Order creation - Format: JSON
- URL:
https://api.adverfly.com/webhooks/loyalty/order - Webhook signature key: the loyalty program's
api_key(visible in your Adverfly dashboard under Loyalty → Integrations).
Shopify signs every request with X-Shopify-Hmac-Sha256 (base64 HMAC-SHA256 of the raw body using your program api_key). Our Lambda verifies the signature, resolves the workspace from X-Shopify-Shop-Domain, and INSERTs the debit row. A partial UNIQUE index on (workspace_id, reference_id) WHERE reference_type='coupon_use' rejects duplicates, so Shopify retries + our 15-min backup sync can both fire without double-counting.
Backup path: even if the webhook is unreachable, our existing 15-min Shopify Orders API sync extracts the same discount_codes and feeds them into the same dedup'd processor. Webhook handles the snappy UX (seconds); sync is the safety net.
Persistent customer voucher
Each customer gets one Shopify discount code on the first balance-positive event. The code string never rotates — only its value field is updated via PUT whenever the balance moves. Multi-use is enabled (usage_limit=null, prerequisite_customer_ids=[customer]) so the customer can spend the credit across multiple checkouts. Each use is detected by the webhook (or backup sync), debits the balance, and triggers a fresh PUT to keep Shopify's code value in step.
Customers see this code on the balance card, in mails, and via the SDK's getBalance().coupon_code field.
Related
- Pixel installation
- Liquid snippets — pre-built drop-ins (badge, cart-drawer, balance pill)
- Loyalty REST API — same endpoints the SDK calls, for non-browser use
Loyalty API Overview
Architecture, data model, and terminology for the Adverfly Loyalty platform.
The Adverfly Loyalty platform powers cashback-style loyalty programs. Customers earn cashback on every purchase, redeem it via a persistent Shopify discount code, and unlock tier gifts at higher spend levels.
This page is the starting point — read it first, then drill into authentication, REST reference, webhooks, or data model.
Architecture in one diagram
┌────────────────────────────────┐
│ api.adverfly.com │
│ (single Custom Domain) │
└─┬──────────┬──────────┬────────┘
│ │ │
┌─────▼────┐ ┌──▼─────┐ ┌─▼────────────────┐
│ loyalty │ │ web │ │ Storefront SDK │
│ /v1/* │ │ hooks │ │ window.adverfly │
│ │ │ /* │ │ .loyalty.* │
└─────┬────┘ └──┬─────┘ └─┬────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────┐
│ Adverfly backend (Lambda) │
│ ─ session-token verify │
│ ─ HMAC verify (Shopify) │
│ ─ compute-balance on-the-fly │
│ ─ sync customer coupon │
│ ─ dispatch tier unlocks │
└─┬──────────────────────────┬───┘
│ │
┌─────▼─────┐ ┌─────▼─────────────┐
│ Aurora │ │ Shopify Admin │
│ enduser │ │ GraphQL │
│ cluster │ │ ─ discountCode* │
│ ─ loyalty │ │ ─ discountAuto* │
│ tables │ └───────────────────┘
└───────────┘
Core concepts
Cashback (the earning side)
- A workspace has one
loyalty_program(single-program rule). - Earning is on-the-fly — there are no per-purchase "credit rows" written. Adverfly walks each purchase × cashback_rate × tier_multiplier at query time.
- The rate is configured via a
loyalty_earn_rulewithtrigger_type='purchase'. - Per-product or per-category overrides live in the same rule's
conditions.overrides[]JSONB.
Balance (the redeemable side)
balance = earned - redeemed, computed live on every read.earnedcomes from thetransactionsClickHouse table (Shopify orders ingestion).redeemedcomes from theloyalty_transactionsRDS table — every debit is a row withtype='redeemed'.
Persistent customer coupon
One Shopify discount code per customer, minted lazily on the first balance event. The code string never rotates; only its value is updated via the Shopify Admin GraphQL discountCodeBasicUpdate mutation whenever the balance moves.
- Stored in
loyalty_coupons(the GraphQL Node GID undershopify_discount_id). - Set up with
usageLimit=null+combinesWith={order,product,shipping}so the customer can reuse it across checkouts and stack it with tier-gift discounts.
Coupon-use detection
When the customer uses the code at checkout, two independent paths debit the balance:
- Webhook (instant) — Shopify fires
orders/createto/webhooks/loyalty/order. The Lambda parsesdiscount_codes[], matches againstloyalty_coupons.code, INSERTs intoloyalty_transactionswithreference_type='coupon_use', then PUTs the new value to Shopify. - 15-min API sync (backup) — the existing Shopify Order API poll (
fetch-from-shopify.ts) extracts discount info and feeds the same processor.
Both paths use INSERT ... ON CONFLICT (workspace_id, reference_id) DO NOTHING. The partial UNIQUE index guarantees no double-debit regardless of which path arrives first.
Tier unlock gifts
When a customer crosses a tier threshold and that tier has an unlock_reward_id:
- Local entitlement —
loyalty_transactionsrow inserted,customers.properties.tier_unlock_gifts[tier_id] = null. - Shopify automatic discount —
discountAutomaticBasicCreatemutation: 100% off the gift product, scoped to the customer. No code entry needed. - Storefront snippet — cart-drawer "Claim" button calls
/cart/add.js→ discount fires at checkout → line drops to €0. - Idempotent —
tier_unlock_gifts[tier_id]is set to the discount GID after success; next cron run skips. A mid-loop crash retries only the missing mints.
The automatic discount sets combinesWith.orderDiscounts=true so the customer can still use their persistent code on the same order.
Custom earn rules
Non-purchase earn rules (signup bonus, review, birthday, custom). Each rule gets a webhook_token; the merchant pastes the URL into TrustPilot / Yotpo / Zapier / their CRM. See Webhooks.
Terminology
| Term | Meaning |
|---|---|
| Program | The one loyalty_program row per workspace. Holds branding, default cashback %, currency, api_key. |
| Tier | A level in the ladder (Bronze/Silver/Gold/…). Has a min_spend_threshold, an earn_multiplier, and an optional unlock gift. |
| Earn Rule | A way to earn balance. Purchase rules are auto-applied; custom rules fire via webhook. |
| Reward | A redeemable entry. Currently only used as tier-unlock-gift target (no customer-facing catalog). |
| Persistent Coupon | One Shopify code per customer; value tracks balance. |
| Coupon Use | An order that applied a customer's persistent code → balance debit. |
| Session Token | HMAC-SHA256 of email:exp_ts signed by the program's api_key. Required for Tier-2 SDK calls. |
Where things live in code
| Concept | Backend path |
|---|---|
| API gateway routes | aws-services/api-gateways/api-gateway-loyalty-public.ts + api-gateway-webhooks.ts |
| Balance compute | lib/queries/loyalty-public/compute-balance.ts |
| Persistent coupon sync | lib/services/loyalty-public/sync-customer-coupon.ts |
| Coupon-use detection | lib/services/loyalty-public/process-order-coupon-uses.ts |
| Tier unlock cron | lib/services/loyalty-public/dispatch-tier-unlocks.ts |
| Custom earn rule fire | lib/services/loyalty-public/webhook-earn-rule.ts |
| Shopify GraphQL helpers | lib/handlers/apis/shopify/create-discount-code.ts |
| Mail rendering | lib/services/loyalty-public/render-balance-email.ts |
| Schema (RDS enduser cluster) | src/models/enduser/migrations/*.sql |
Read next
- Authentication —
api_key, session tokens, webhook signing - Public API Reference — Every REST endpoint
- Incoming Webhooks — Custom Earn + Shopify Order
- Shopify Setup — End-to-end wiring
- Data Model — Every table
Authentication
API keys, session tokens, and webhook signing for the Loyalty platform.
The Loyalty platform uses three different auth mechanisms depending on what's calling:
| Auth | Used by | Carrier |
|---|---|---|
| Program API Key | Server-to-server (your shop's backend, custom earn webhooks) | X-Adverfly-Key header |
| Session Token | Browser SDK (window.adverfly.loyalty.*) | Authorization: Bearer … |
| Shopify HMAC | Shopify webhooks → Adverfly | X-Shopify-Hmac-Sha256 header |
All three ultimately tie back to ONE secret: loyalty_programs.api_key. It's the program's master key — keep it server-side.
Program API Key
Generated automatically when the loyalty program is created. Find it in the Adverfly dashboard under Loyalty → Integrations (Loyalty API Key card).
Format: adv_loy_<32 hex chars>
Example: adv_loy_8f3c5d1e9a2b7f4c6d8a0e3f1b9c5d7e
Where to use it directly:
- Custom Earn webhooks — set
X-Adverfly-Key: <api_key>header (or?key=<api_key>query param fallback) when firing/webhooks/loyalty/earn. - Shopify Order webhook — set the api_key as the webhook signing key in Shopify Admin (Adverfly verifies HMAC with the api_key, not Shopify's app secret).
- Storefront session-token block (Shopify Liquid) — store as a Shopify metafield
adverfly.api_key, read server-side to mint per-customer session tokens.
Never expose to the browser. The key is the program's root credential — anyone with it can mint balance via custom-earn endpoints.
Session Token (browser SDK)
The Adverfly Loyalty SDK's customer-scoped methods (getBalance, updateProfile, getUnlockedGifts) require a session token that proves "this browser is acting on behalf of <email>". The token is minted server-side via Liquid (or your own framework's equivalent) and assigned to window.adverfly.loyalty_session_token before the SDK is used.
Token shape
<email>:<expiry_unix_ts>:<hex_hmac_sha256>
email— lowercased customer email.expiry_unix_ts— seconds since epoch when the token stops working. We recommend 3600 (1 hour) so each page render mints a fresh one.hex_hmac_sha256— HMAC-SHA256 over<email>:<expiry_unix_ts>using the programapi_keyas the secret.
Shopify Liquid block
Paste this once near the top of layout/theme.liquid. Reads the api_key from a server-only metafield, mints a fresh token per page render, never leaks the key to the browser.
{%- if customer -%}
{%- assign adv_api_key = shop.metafields.adverfly.api_key -%}
{%- assign adv_exp_ts = "now" | date: "%s" | plus: 3600 -%}
{%- assign adv_email = customer.email | downcase -%}
{%- assign adv_payload = adv_email | append: ":" | append: adv_exp_ts -%}
{%- assign adv_sig = adv_payload | hmac_sha256: adv_api_key -%}
<script>
window.adverfly = window.adverfly || [];
window.adverfly.loyalty_session_token = {{ adv_payload | append: ":" | append: adv_sig | json }};
window.adverfly.customer_id = {{ adv_email | json }};
</script>
{%- endif -%}
Non-Shopify storefronts
Generate the same shape server-side in whatever stack you use:
// Node example
const crypto = require("crypto");
const email = customer.email.toLowerCase();
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = `${email}:${exp}`;
const sig = crypto.createHmac("sha256", apiKey).update(payload).digest("hex");
const token = `${payload}:${sig}`;
// Assign to window.adverfly.loyalty_session_token before the SDK is used
Verification (server side)
Done automatically by Adverfly when the browser calls Tier-2 endpoints. If you want to verify yourself (custom backend that proxies to Adverfly), the logic is:
- Split on
:intoemail,exp,sig - Reject if
exp < now - Recompute HMAC over
<email>:<exp>with the program api_key - Constant-time compare against the supplied
sig
If any step fails, return 401 { code: "auth_required" }.
Shopify HMAC (incoming webhooks)
Shopify signs the raw POST body with HMAC-SHA256, base64-encoded, in the X-Shopify-Hmac-Sha256 header on every webhook.
In Adverfly's webhook handler we verify against the loyalty program's api_key — NOT the Shopify app's shared secret. The merchant configures their loyalty api_key as the webhook signing key when they create the webhook in Shopify Admin.
Why the program key, not the Shopify app secret
- Per-merchant keys → each merchant's webhooks isolated; one leaked key doesn't compromise other shops.
- Simpler onboarding — merchant copy-pastes one value (already visible in the dashboard) instead of needing app-level secrets.
- Same key already powers session tokens + custom earn → single secret to manage.
Verification code (illustrative)
import * as crypto from "crypto";
function verify(rawBody: string, signatureB64: string, programApiKey: string) {
const expected = crypto
.createHmac("sha256", programApiKey)
.update(rawBody, "utf8")
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureB64)
);
}
Rotation
Program API key rotation — Coming soon. Today the api_key is fixed for the life of the program. If you suspect compromise:
- Email support to schedule a rotation
- Plan a coordinated update: new api_key → reissue Shopify webhook signing key → reissue any Liquid metafield → rotate custom-earn webhook senders
Per-rule webhook tokens (in the Custom Earn tab) can be rotated independently by deleting + recreating the rule — the URL changes, the api_key stays put.
Public API Reference
REST endpoints under api.adverfly.com/loyalty/v1/*
All Loyalty endpoints live under https://api.adverfly.com/loyalty/v1/* (HTTP API v2, regional). CORS is open (Access-Control-Allow-Origin: *) because storefront snippets call from arbitrary merchant domains; all sensitive endpoints are session-token-gated at the Lambda level.
Endpoint matrix
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /program | Anonymous-safe | Public program snapshot (tiers + earn rules + rewards + branding) |
| GET | /balance | Session Token | Live balance + coupon code + unlocked gifts for one customer |
| POST | /balance-lookup | Anonymous | Trigger the magic-link "balance email" |
| GET | /preferences | Signed magic-link URL | Render the public preferences page |
| POST | /preferences | Signed magic-link URL | Toggle subscription flags |
| GET | /unsubscribe | Signed magic-link URL | One-click unsubscribe |
| POST | /profile | Session Token | Customer-initiated profile update (birthday, locale, custom fields) |
GET /program
Public snapshot of the loyalty program — tiers, earn rules, rewards, branding. Used by storefront snippets to render badges and labels without needing an authenticated session.
Query params:
workspace_id(required) — the merchant's workspace id.
Response:
{
"data": {
"program": { "id": "...", "name": "...", "currency": "EUR", "branding": {...} },
"tiers": [{ "id": "...", "name": "Silver", "min_spend_threshold": 0, "earn_multiplier": 1, ... }],
"earn_rules": [{ "id": "...", "trigger_type": "purchase", "earn_type": "percentage", "earn_value": 5, ... }],
"rewards": [{ "id": "...", "name": "...", "reward_type": "free_product", ... }]
}
}
Behind the scenes: the snapshot is precomputed and CloudFront-cached (60s stale-while-revalidate). Refreshes when the merchant saves changes in the dashboard.
GET /balance
Live balance + persistent coupon code + unlocked tier gifts for one customer. Session-token gated.
Query params:
workspace_id(required)customer_id(required) — the customer's lowercased email. Must match the token.
Headers:
Authorization: Bearer <session_token>(see Authentication)
Response:
{
"data": {
"balance": 12.50,
"total_earned": 47.30,
"total_redeemed": 34.80,
"tier_name": "Gold",
"currency": "EUR",
"coupon_code": "ADV-A1B2-C3D4-E5F6",
"unlocked_gifts": [
{
"tier_id": "...",
"tier_name": "Gold",
"reward_id": "...",
"reward_name": "Ceramic Mug",
"reward_type": "free_product",
"shopify_product_id": "...",
"shopify_discount_id": "gid://shopify/DiscountAutomaticNode/...",
"reward_image_s3_key": "..."
}
]
}
}
Errors:
400— missingworkspace_idorcustomer_id401— missing / invalid / expired session token, OR token email ≠ requested customer_id
POST /balance-lookup
Public "what's my balance?" magic link mail. No auth — anyone can request a mail to any email, but the mail itself only sends if the email is a known customer (no enumeration leakage in the response).
Body:
{ "workspace_id": 123, "email": "customer@example.com" }
Response:
{ "data": { "ok": true } }
(Always returns ok=true to avoid leaking which emails are customers.)
GET / POST /preferences
Public preferences page — same Lambda renders the toggle UI (GET) and processes flips (POST). Auth is a signed magic-link token (one-shot URL in the mail), not a session token.
Query params (GET):
token(required) — the signed token from the magic link
The Lambda renders an HTML page with subscription toggles. POST submits the form.
GET /unsubscribe
One-click unsubscribe from a signed magic-link URL. Same token pattern as preferences. Sets customers.properties.unsubscribed_at = now().
POST /profile
Customer-initiated profile update — birthday, locale, custom fields. Session-token gated.
Body:
{
"workspace_id": 123,
"customer_id": "customer@example.com",
"properties": {
"birthday": "1990-05-15",
"locale": "de",
"favorite_color": "blue"
}
}
Validation:
birthdaymust beYYYY-MM-DD(server validates).- Values must be
string|number|boolean|null(arrays and nested objects rejected). - Max 32 keys per request, max 1024 chars per value.
- Server-managed keys are silently rejected with
400 invalid_field:last_birthday_credited_year,tier_unlock_gifts,granted_tier_unlocks,granted_tier_unlocks_shopify,after_earn,monthly_summary,subscribed_at,unsubscribed_at.
Response:
{ "data": { "properties": { ... } } }
(Merged JSONB after the update.)
Rate limits
Stage-level throttling (HTTP API v2 defaultRouteSettings):
- Burst: 1000 req/s
- Sustained: 500 req/s
Per-customer quota is not enforced today — the session-token + email-match check is the practical abuse barrier.
SDK wrapper
All endpoints have a matching method in the Pixel v4 SDK — see Loyalty SDK. Most storefronts should use the SDK rather than calling these endpoints directly; it handles tokens, caching, and error shapes for you.
Incoming Webhooks
Custom Earn endpoint + Shopify Order webhook
Two server-to-server endpoints under https://api.adverfly.com/webhooks/loyalty/*. Both rely on the program's api_key for auth — no app-level OAuth, no separate webhook secrets.
| Path | Used by | Auth |
|---|---|---|
/webhooks/loyalty/earn | External systems (TrustPilot, Yotpo, Klaviyo, Zapier, custom CRMs) | X-Adverfly-Key header |
/webhooks/loyalty/order | Shopify (orders/create) | X-Shopify-Hmac-Sha256 HMAC over raw body |
Custom Earn — /webhooks/loyalty/earn
Fires a non-purchase earn rule (signup bonus, review reward, birthday, custom). Each rule in the dashboard has its own webhook_token exposed under the Custom Earn tab — that's the id you put in the URL.
Request
curl -X POST "https://api.adverfly.com/webhooks/loyalty/earn?id=<RULE_TOKEN>&email=customer@example.com" \
-H "X-Adverfly-Key: <PROGRAM_API_KEY>"
GET works too — pasteable into any webhook field that doesn't know about HTTP methods (Klaviyo flows, some Zapier zaps). The Lambda reads query params + headers and ignores the method.
Parameters
| Field | Where | Notes |
|---|---|---|
id | query | Earn rule's webhook_token (UUID). Rotate by deleting + recreating the rule in the dashboard. |
email | query | Customer email. Used as identity + default idempotency_key. |
X-Adverfly-Key | header | Program api_key. Fallback: &key=<api_key> query param for clients that can't send headers. |
idempotency_key | query (optional) | External event id. Defaults to email so the same customer can't be double-credited by the same source. |
Rate limits per rule
Configurable per rule in the dashboard:
- Unbegrenzt — no limit (default)
- Einmalig — once per customer, forever
- Monatlich — once per customer per 30 days
- Halbjährl. — once per customer per 180 days
- Jährlich — once per customer per 365 days
Dedup happens server-side via the webhook_log audit table (queried for the configured window per (rule, idempotency_key)).
Response
{ "ok": true, "amount_credited": 5, "reference_id": "..." }
Status 200 in all expected cases (including rate-limited + duplicate). The body's ok + reason fields tell you what happened — Adverfly treats every fire as best-effort so retry-on-error doesn't accidentally double-credit.
Outcome codes
kind | Meaning |
|---|---|
ok | Credit applied. |
duplicate | The (rule, idempotency_key) was already credited within the rate-limit window. |
rate_limited | Rate-limit window not yet expired for this (rule, customer). |
rule_not_found | Unknown id token. |
rule_disabled | Rule exists but is toggled off. |
trigger_not_allowed | Rule is a purchase rule (purchase rules don't fire via webhook — they go through the regular pixel/platform sync path). |
missing_key / bad_key | X-Adverfly-Key missing or wrong. |
missing_email | email query param missing. |
internal_error | Unexpected server error. |
Shopify Order — /webhooks/loyalty/order
Instant coupon_use detection. Shopify fires orders/create after every order; Adverfly parses discount_codes[] and discount_applications[], matches against the customer's persistent loyalty coupon, and debits the balance.
Request
Shopify POSTs the full Order JSON. Headers include:
X-Shopify-Hmac-Sha256: <base64-hmac-sha256-of-raw-body>
X-Shopify-Shop-Domain: myshop.myshopify.com
X-Shopify-Topic: orders/create
HMAC verification
Adverfly verifies the signature against the loyalty program's api_key — the merchant sets the api_key as the webhook signing key when configuring the webhook in Shopify Admin. See Authentication → Shopify HMAC.
Workspace resolution
The Lambda looks up the workspace by X-Shopify-Shop-Domain against the apis table (integration_key IN ('shopify_loyalty', 'shopify')). If no workspace matches, returns 200 with { skipped: "no_program" } — Shopify thinks delivery succeeded; we just don't process.
Idempotency
INSERT into loyalty_transactions with reference_type='coupon_use' + reference_id=<order_id>. A partial UNIQUE index on (workspace_id, reference_id) WHERE reference_type='coupon_use' makes the insert ON CONFLICT NOOP — Shopify retries, simultaneous webhook + 15-min-sync race, and any other replay all collapse to one debit.
Response
{ "ok": true, "inserted": 1, "skipped_duplicate": 0, "skipped_no_match": 0, "errored": 0 }
Status codes:
200— processed (including skips and partial failures)400— missing headers or malformed body401— invalid HMAC signature
200 on internal errors too (logged server-side) so Shopify doesn't aggressively retry transient bugs — the 15-min Shopify Order API sync is the backup safety net.
Backup detection path (no webhook required)
Even if a merchant never configures the Shopify webhook, the existing 15-min Shopify Order API poll (fetch-from-shopify.ts) extracts discountCodes[].code + discountCodes[].amountSet.shopMoney.amount from every order and feeds the same processor (processOrderCouponUses) — same idempotency, same dedup. Webhook is the snappy UX (~seconds); 15-min API sync is the safety net.
Merchant onboarding
For end-to-end Shopify wiring (App install + webhook creation + storefront snippet), see Shopify Setup.
Shopify Setup
End-to-end Shopify wiring — app install, webhook, storefront snippets.
This is the merchant-facing setup guide for hooking a Shopify store into Adverfly Loyalty. After you've finished, your customers will:
- Earn cashback on every purchase (live, no manual claim)
- See their balance + persistent coupon code in widgets / mails
- Unlock free product gifts at tier thresholds, auto-discounted at checkout
- Stack the loyalty code with any other discount
There are four pieces to wire up. Most merchants are live within 30 minutes.
1. Connect Shopify (if not already)
The Adverfly Loyalty app needs read access to orders + customers + products, and write access to discounts.
- Adverfly dashboard → Integrations → search "Shopify" → Connect
- Authorize the requested scopes (
read_orders,read_customers,read_products,write_discounts) - Your shop appears in Integrations → Active
If you already had Shopify connected for analytics, reconnect to pick up the new write_discounts scope — Adverfly mints customer-coupons via this scope.
2. Create the Order webhook
Lets Adverfly debit balances the moment a customer uses their loyalty code (instead of waiting for the 15-min order-API poll).
- Shopify Admin → Settings → Notifications → scroll to Webhooks → Create webhook
- Event: Order creation
- Format: JSON
- URL:
https://api.adverfly.com/webhooks/loyalty/order - Webhook signature key: your loyalty program's
api_key(Adverfly dashboard → Loyalty → Integrations → Loyalty API Key card) - Save
Test it: place a test order, check Loyalty → Members → [the customer] in Adverfly — the debit should show up within seconds.
What if I skip this step? The 15-min Shopify Order API sync still picks up uses — you just trade ~seconds for ~minutes of latency. We recommend the webhook for snappy UX but it's optional.
3. Install the storefront blocks (Liquid)
The Adverfly Liquid snippets render the customer-facing pieces:
- Session Token — required base block, lives in
layout/theme.liquid, signs a per-page session token for the SDK - Product Badge — "Earn +€2.50 credit" pill on product pages
- Balance Badge — inline pill showing the logged-in customer's balance
- Cart-Drawer Tier Gifts — claim button for unlocked free-product gifts inside the cart drawer
- Balance Link —
<a href="#adv-balance">opens the balance lookup modal
Find ready-to-copy versions in the Adverfly dashboard under Loyalty → Integration. Each block is a single <script> + matching DOM element you paste into the right Liquid template.
Session token block (do this first)
This is the prerequisite for all token-gated snippets. Set your loyalty api_key as a Shopify metafield:
- Settings → Custom data → Shop → Add definition
- Namespace and key:
adverfly.api_key - Type: Single line text
- Save → paste your loyalty api_key as the value
Then paste this block near the top of layout/theme.liquid:
{%- if customer -%}
{%- assign adv_api_key = shop.metafields.adverfly.api_key -%}
{%- assign adv_exp_ts = "now" | date: "%s" | plus: 3600 -%}
{%- assign adv_email = customer.email | downcase -%}
{%- assign adv_payload = adv_email | append: ":" | append: adv_exp_ts -%}
{%- assign adv_sig = adv_payload | hmac_sha256: adv_api_key -%}
<script>
window.adverfly = window.adverfly || [];
window.adverfly.loyalty_session_token = {{ adv_payload | append: ":" | append: adv_sig | json }};
window.adverfly.customer_id = {{ adv_email | json }};
</script>
{%- endif -%}
The api_key never reaches the browser — only the signed token, which expires in 1 hour.
4. (Optional) Custom Earn webhooks
For non-purchase earn rules (signup, review, birthday, custom):
- Adverfly dashboard → Loyalty → Custom Earn → create a rule (name + € amount + rate limit)
- Save → a unique webhook URL appears under the rule
- Paste that URL into the external system (Klaviyo flow, Yotpo trigger, Zapier webhook step, …)
- Set the
X-Adverfly-Keyheader (or&key=query param) to your loyalty api_key
See Incoming Webhooks → Custom Earn for full request/response details.
What runs end-to-end
Customer flow Adverfly side
───────────── ─────────────
1. Buys €100 of products → ClickHouse `transactions` row (via Shopify Order sync)
compute-balance: earned += €5 (at 5% cashback)
syncCustomerCoupon: mint Shopify discount with value €5
2. Receives "Du hast €5 erhalten" mail ← after-earn cron (every 15 min)
3. Adds gift T-shirt to cart (Gold tier) ← Cart-drawer Tier Gift snippet
Shopify auto-discount fires ← discountAutomaticBasicCreate (minted at unlock)
4. Enters loyalty code at checkout → persistent code applies, drops cart by €5
5. Places order → Shopify orders/create webhook (instant)
processOrderCouponUses: INSERT loyalty_transactions
compute-balance: redeemed += €5
updateShopifyDiscountCode: value PUT to new balance (€0)
6. Earns cashback on the new order → (loop back to step 1)
Troubleshooting
"Customer's code says €0 but their balance is €10"
→ Shopify-side sync race. The merchant-side balance is the source of truth (getBalance is correct). Wait for the next sync (≤15 min) or trigger a syncCustomerCoupon from the admin UI.
"Tier gift code isn't applying"
→ Check customers.properties.tier_unlock_gifts[<tier_id>] — should be a Shopify GID, not null. If null, the Shopify mint failed (look at customers.properties.tier_unlock_gifts_error or the tier-unlocks Lambda CloudWatch logs).
"Webhook test fires but balance doesn't update"
→ HMAC signature mismatch. Confirm the Shopify webhook signing key = your loyalty api_key exactly. The webhook also returns 200 on no_program if X-Shopify-Shop-Domain doesn't match a connected workspace.
"Customer says their gift didn't auto-add to cart"
→ The cart-drawer snippet only fires on cart:open / cart:refresh events. Some themes don't dispatch these — try a manual page reload after adding to cart. Coming soon: a more robust event hook.
Read next
- Loyalty SDK — Custom storefronts beyond the Liquid snippets
- Public API Reference — REST endpoints
- Data Model — Backend tables
Data Model
Every table the Loyalty platform reads and writes.
The Loyalty platform stores customer-facing state in a dedicated end-user Aurora cluster (separate from the main platform RDS) to keep merchant-admin traffic and end-shopper traffic isolated.
Cluster split
| Cluster | Tables | Used by |
|---|---|---|
End-user (rds-enduser.ts) | loyalty_programs, loyalty_tiers, loyalty_rewards, loyalty_earn_rules, loyalty_coupons, loyalty_transactions, customers, webhook_log, email_send_log, email_subscriptions (renamed → customers), referrals, campaigns | All public loyalty endpoints + crons |
Main (rds-loyalty.ts shared) | apis (Shopify creds), email_templates, email_bounces, email_domains | Mail rendering + Shopify auth |
| ClickHouse | transactions (orders, used for cashback compute) | compute-balance |
Tables
loyalty_programs
One row per workspace. Singleton enforced via partial UNIQUE index on (workspace_id) WHERE status != 'archived'.
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
workspace_id | integer | Soft ref to main cluster's workspaces.id |
name | text | Display name in dashboard / mails |
api_key | text | Program master secret (adv_loy_…). Powers session tokens, webhook HMAC, custom-earn auth |
currency | text | ISO 4217 (EUR, USD, …) |
credit_expiry_days | integer | NULL = no expiry. Coming soon: enforcement |
sms_login_enabled / whatsapp_login_enabled | boolean | Reserved for future channels — disabled in UI today |
widget_config | jsonb | Storefront widget config |
branding | jsonb | { logo_url, default_language, balance_cap, card_style, earning_source } |
status | text | active / archived |
loyalty_tiers
The tier ladder. Ordered by position within a program.
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
program_id | uuid | FK to loyalty_programs |
name, position, color, icon_s3_key | display | |
min_spend_threshold | numeric | € of lifetime spend to enter this tier |
earn_multiplier | numeric | Multiplied against base cashback %. 0 = "Kein Cashback" |
perks | jsonb (string[]) | Free-text list of perks shown in widget / mails |
redemption_policy | jsonb | Optional { allowed_reward_types, blocked_reward_types } |
unlock_reward_id | uuid | FK-less ref to loyalty_rewards.id — the one-time gift granted on first reaching this tier |
loyalty_rewards
Today only used as tier-unlock-gift targets — there's no customer-facing reward catalog UI anymore. The merchant defines the available gifts here and assigns them to tiers via loyalty_tiers.unlock_reward_id.
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
program_id | uuid | FK |
name, description | display | |
reward_type | text | free_product / free_shipping / external_voucher are the semantically alive types |
cost | numeric | Historical (€ cost to redeem). 0 for free_product. |
value_config | jsonb | { shopify_product_id, custom_message?, ... } |
image_s3_key | text | Used in storefront snippet |
min_tier_id | uuid | Coming soon: enforced. Today purely informational. |
stock_limit, stock_redeemed | integer | Coming soon: enforced |
enabled | boolean | toggle |
loyalty_earn_rules
How customers earn cashback / bonus credit.
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
program_id | uuid | FK |
name | text | Internal label |
trigger_type | text | purchase / custom / signup / review / birthday |
earn_type | text | percentage (cashback %) / fixed (€ amount) |
earn_value | numeric | The % or € depending on earn_type |
conditions | jsonb | For purchase rules: { overrides: [{ product_id?, category_id?, rate_override }] } |
tier_overrides | jsonb | Per-tier rate override map { tier_id: rate_or_multiplier } |
webhook_token | uuid | Per-rule unique token for /webhooks/loyalty/earn |
limit_window_days | integer | NULL=unlimited, 0=once-forever, N=once per N days (per customer) |
enabled | boolean | toggle |
loyalty_coupons ⭐
One persistent Shopify discount code per (workspace, customer).
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
workspace_id | integer | scope |
customer_id | text | customer email (lowercased) |
program_id | uuid | FK to loyalty_programs |
code | text | The customer-facing string (ADV-A1B2-C3D4-E5F6). Stable for life. |
shopify_discount_id | text | GraphQL Node GID (gid://shopify/DiscountCodeNode/…) returned by discountCodeBasicCreate |
shopify_price_rule_id | text | Legacy REST-era field; not written anymore but column kept for old rows |
last_synced_value | numeric | Last € value pushed to Shopify (used for idempotent sync skip) |
last_synced_at | timestamp | |
last_sync_error | text | Most recent Shopify error message; NULL on clean sync |
UNIQUE (workspace_id, customer_id) and UNIQUE (workspace_id, code).
loyalty_transactions ⭐
The debit ledger. Every balance-changing event that's not a pure purchase-cashback (which is computed on the fly) lands here.
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
program_id, workspace_id, customer_id | scope | |
type | text | redeemed (debit) / adjustment (admin or tier-gift credit) |
amount | numeric | Negative for redeems, positive for adjustments |
balance_after | numeric | snapshot at the time (historical; we recompute live now) |
reference_type | text | reward / tier_unlock / coupon_use / admin_adjustment |
reference_id | text | Source ID — reward_id for redeems, tier_id for unlocks, shopify order_id for coupon_use |
description | text | Free-text label |
created_at | timestamp |
Partial UNIQUE index idx_loyalty_tx_coupon_use_dedup ON (workspace_id, reference_id) WHERE reference_type='coupon_use' is the idempotency guarantee for coupon-use detection — Shopify webhook + 15-min sync can both fire, only one row lands.
customers
Unified end-user table — used by loyalty AND future end-user features (surveys, reviews, account portal).
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
workspace_id | integer | scope |
domain | varchar(30) | loyalty / future surveys etc. — discriminator for cross-domain reuse |
email | text | identity |
properties | jsonb | Flexible bag — birthday, locale, subscription flags, tier_unlock_gifts map, and customer-provided fields |
created_at, updated_at | timestamp |
Server-owned property keys (rejected on customer updateProfile): last_birthday_credited_year, tier_unlock_gifts, granted_tier_unlocks, granted_tier_unlocks_shopify, after_earn, monthly_summary, subscribed_at, unsubscribed_at.
tier_unlock_gifts map
{
"tier-uuid-1": "gid://shopify/DiscountAutomaticNode/12345",
"tier-uuid-2": null
}
Key existence = local entitlement granted (loyalty_transactions row exists). Value = Shopify automatic-discount GID for free_product rewards. null = grant happened but Shopify mint hasn't (yet) — retried on next dispatchTierUnlocks run.
webhook_log
Audit / dedup for incoming earn-rule webhooks (/webhooks/loyalty/earn).
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
workspace_id, domain, source_id | scope (domain='loyalty', source_id=loyalty_earn_rules.id) | |
customer_id | text | |
idempotency_key | text | Either external event id or fallback email |
amount_credited | numeric | What we credited |
metadata | jsonb | The original payload (truncated) |
ip_address | inet | Source IP for abuse forensics |
created_at | timestamp |
UNIQUE (domain, source_id, idempotency_key) — duplicate INSERTs fail cleanly, no race.
email_send_log
Idempotency for outbound lifecycle mails (after_earn, monthly_summary). UNIQUE (workspace_id, email_type, dedupe_key).
email_subscriptions (legacy name)
Now consolidated into customers table. The subscription flags (after_earn, monthly_summary, subscribed_at, unsubscribed_at) live in customers.properties.
referrals
Coming soon. Table exists with (referrer_email, referee_email, status, ...) but the UI + Shopify discount issuance is not yet wired.
campaigns
Coming soon. Renamed from loyalty_campaigns. Holds campaign metadata for targeted lifecycle pushes — currently no live consumer.
Migration numbering
Migrations live in backend/src/models/enduser/migrations/. Numbered sequentially independently from the main cluster (0001_* upward). The done_ filename prefix means the migration has been applied to both dev + prod.
Latest:
0017_done_earn_rule_limits.sql— per-rule rate limit window0018_done_loyalty_coupons.sql— persistent coupon table + partial UNIQUE coupon_use dedup index
Related
- Overview — how the tables come together
- Public API — what the endpoints read/write
- Authentication —
api_keyrole across the system