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, points?, … } — points = { balance, rate, label } when the merchant enabled the points display mode; render it instead of the currency amount |
updateProfile(props) | { properties } — merged JSONB after the update |
getReferralCode() | { code, reward_provider, referrer_reward_minor, referee_reward_minor, referrer_discount_percent, referee_discount_percent } — the customer's permanent personal referral code + the Referrals program's reward terms. Rejects with { status: 404 } when the workspace runs no active referral program. |
claimReferralCode(code) | { outcome } — one of ok, already_claimed, self_referral, code_not_found. Links the current session's customer as referee of the code's owner. |
If the token is missing or expired, the call rejects with { code: "auth_required" }.
Referral link capture is automatic. When a visitor lands with ?ref=CODE in the URL, the pixel stores the code for 30 days and posts it via claimReferralCode as soon as the visitor authenticates (logged-in storefront session). Coupon-based referrals need neither — redeeming the personal code at checkout claims and qualifies the referral server-side. The balance modal (#adv-balance) renders a "Freunde werben" card with the customer's code automatically when a referral program is active.
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 a cashback-ledger redeem booking (dedupe coupon_use:{order_id}), 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) │
│ ─ ledger balance (SUM) │
│ ─ 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). - 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. - Earning is based on platform-confirmed orders only (ClickHouse
transactionsfrom the shop integration) — pixel events are forgeable from the browser and never feed cashback.
Balance (the redeemable side)
Every balance-affecting event is a booking on the append-only cashback ledger (ledger_entries, double-entry, same engine as creator commissions). Earns carry the rate/tier/campaign frozen as of the purchase; refunds and expiry are explicit bookings. balance = SUM over the customer's customer_cashback:{email} account — nothing drifts retroactively. Earning only happens for live programs and starts at launch; drafts never accrue.
Points display mode (optional)
A merchant can render balances as points ("1.250 Punkte", "500 PTS", …) instead of currency — configured in Loyalty → Settings (branding.points: rate + label). This is presentation only: the ledger, every booking and the Shopify code value stay in the program currency; widget, mails and the SDK payload (getBalance().points) convert at display time.
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, the Shopify Order API sync (fetch-from-shopify.ts, every 15 minutes per workspace) extracts the discount info, matches it against loyalty_coupons.code, books a redeem on the cashback ledger, then PUTs the new value to Shopify. There is no order webhook.
The ledger's dedupe key coupon_use:{order_id} makes a re-synced order a no-op — no double-debit.
Tier unlock gifts
When a customer crosses a tier threshold and that tier has an unlock_reward_id:
- Local entitlement —
customers.properties.tier_unlock_gifts[tier_id] = null(store-credit gifts additionally book a ledger adjustment). - 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",
"points": {
"balance": 1250,
"total_earned": 4730,
"total_redeemed": 3480,
"rate": 100,
"label": "Punkte"
},
"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": "..."
}
]
}
}
points is only present when the merchant enabled the points display mode (Loyalty → Settings). It is pure presentation: the same values pre-converted at rate points per 1 currency unit — the balance/coupon_code fields stay the source of truth (the code applies the currency value at checkout). Render points.balance + " " + points.label when present.
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
One server-to-server endpoint under https://api.adverfly.com/webhooks/loyalty/*. It relies 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 |
There is no order webhook. Everything an order triggers — loyalty coupon uses, referral qualification, raffle tickets — is detected by the Shopify Order API sync that runs every 15 minutes per workspace. Nothing to configure in Shopify Admin.
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. |
Order detection (no webhook)
The Shopify Order API sync (every 15 minutes per workspace) extracts discountCodes[].code + discountCodes[].amountSet.shopMoney.amount from every order and feeds processOrderCouponUses. The debit lands on the cashback ledger with dedupe key coupon_use:{order_id}, so a re-synced order never double-debits. Expect a coupon use to show up in the member's balance within 15 minutes of checkout, plus the sync's own runtime.
Merchant onboarding
For end-to-end Shopify wiring (App install + 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. Order detection — nothing to configure
Adverfly reads orders through the Shopify Order API sync that runs every 15 minutes per workspace. Coupon uses, referral qualification and raffle tickets are all detected there. There is no order webhook to create in Shopify Admin.
Test it: place a test order, then check Loyalty → Members → [the customer] in Adverfly — the debit shows up after the next sync, at most 15 minutes later.
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 Order API sync (every 15 min)
processOrderCouponUses: book ledger redeem
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, ledger_entries (cashback ledger), 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 } |
ledger_enabled_at | timestamptz | Earn-window anchor — set at creation, moved to the launch moment when a draft goes live. The reconcile sweep only books orders with dt >= this timestamp |
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).
ledger_entries ⭐ (cashback ledger)
The book of record for every balance. Same table shape, booking engine and invariants as the creator-commission ledger on the main cluster — one unified double-entry system, one physical book per cluster. Append-only (DB trigger; the single exception is GDPR erasure, which removes whole bookings under a session GUC).
| Column | Type | Purpose |
|---|---|---|
id | uuid | PK |
workspace_id | integer | Tenant |
booking_id | uuid | One financial event = one booking with ≥ 2 legs summing to exactly 0 |
domain | text | loyalty |
entry_type | text | earn / adjustment / redeem / refund / expire / correction |
account | text | customer_cashback:{email} or a counter account (cashback_expense, cashback_out, cashback_expired) |
amount | numeric | Signed leg amount (major units) |
metadata | jsonb | dedupe_key, order_id, frozen rate_pct / tier_multiplier / campaign_boost, reference_type / reference_id |
created_at | timestamptz |
A customer's balance is SUM(amount) over their customer_cashback:{email} account — earns are frozen at booking time (rate × tier × campaign as of the purchase), so merchant config edits are forward-only and late refunds become explicit refund clawback bookings. Bookings are idempotent via the partial UNIQUE index on (workspace_id, metadata->>'dedupe_key') scoped to customer accounts (earn:{order}, coupon_use:{order}, birthday-{year}:{email}, …).
Writers: the 15-min reconcile sweep in loyalty-run-rewards (earns from ClickHouse transactions for LIVE programs only, refund clawbacks, FIFO expiry, zero-sum verify) plus every adjustment path (admin, birthday, tier gifts, webhooks, workflows, referrals) and the coupon-use redeem detection. ledger_enabled_at is set at program creation (and moved to the launch moment when a draft goes live, so pre-launch orders never earn); the former loyalty_transactions table was verified empty in every workspace and dropped in migration 0028.
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. 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
Live — owned by the standalone Referrals app (not loyalty). One row per claimed referee, scoped to domain='referral'; the referrer's durable personal code lives on their customers row (domain='referral', properties.referral_code). Program config in referral_programs (reward provider: loyalty_credit credits both sides via cashback-ledger bookings; coupon mints Shopify discount codes). Loyalty is an optional reward backend — the app works without a loyalty program.
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