Public Loyalty Portal API
The public loyalty portal API powers the end-user-facing wallet portal. Unlike the rest of the WalletHero API, these endpoints are not authenticated with a Directus Bearer token and do not require workspace-junction membership. They use a short-lived client session token (a signed JWT minted per (client_id, workspace_id)).
Session model
There are three tiers of access in this domain:
- Fully public (no token):
GET /public/loyalty/portal-config— branding/config for a workspace, keyed byworkspace_slug.POST /public/loyalty/session— exchanges a(workspace_slug, client_id)pair for a session token. This is how a portal starts.
- Session-token authed: every other endpoint requires the token issued by
/session, passed via thex-client-tokenrequest header (query-string tokens are no longer accepted — they leak into access logs and browser history). The token is a JWT of typeclient_access, valid for 30 days, and encodes the client and workspace — so token holders only ever see that one client's data in that one workspace.
Missing/invalid/expired tokens return an error (e.g. "Authentication token required", "Invalid token", "Token expired"). The official SDK (PublicLoyaltyService) stores the token after createSession and sends it via the x-client-token header automatically.
The SDK service is constructed directly with a Directus base URL and (optionally) a token:
new PublicLoyaltyService({ apiUrl, token }). Examples below use aportalinstance of it.
Get Portal Config (public)
GET /wallethero-api/public/loyalty/portal-config
Returns the public branding/configuration for a workspace's loyalty portal, looked up by slug. Responds 404 if no portal exists or the portal is disabled.
Auth: Public — no token required
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
workspace_slug | string | Yes | Workspace slug whose portal config to fetch |
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/portal-config?workspace_slug=acme-coffee"Response (200)
{
"data": {
"workspace_id": "WORKSPACE_ID",
"workspace_name": "Acme Coffee",
"workspace_slug": "acme-coffee",
"is_enabled": true,
"logo_url": "https://api.wallethero.app/assets/LOGO_FILE_ID",
"primary_color": "#1a1a1a",
"background_color": "#ffffff",
"welcome_title": "Welcome back!",
"welcome_subtitle": "Track your points and rewards.",
"show_tier": true,
"show_rewards": true,
"show_redemption_history": true,
"show_referral": true,
"cta_label": "View rewards",
"custom_css": null
}
}Response (404)
{ "error": "Loyalty portal not found" }SDK
const config = await portal.getPortalConfig("acme-coffee");Create Session (public)
POST /wallethero-api/public/loyalty/session
Exchanges a (workspace_slug, client_id) pair for a session token plus the client's profile, workspace info, and portal config. The returned token authenticates every other public-loyalty endpoint. Rate limited to 20 requests per minute per IP (429 with Retry-After beyond that).
Auth: Public — no token required
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
workspace_slug | string | Yes | Slug of the workspace the client belongs to |
client_id | string (UUID) | Yes | The client opening the portal |
Example Request
curl -X POST "https://api.wallethero.app/wallethero-api/public/loyalty/session" \
-H "Content-Type: application/json" \
-d '{ "workspace_slug": "acme-coffee", "client_id": "CLIENT_ID" }'Response (200)
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"client": {
"id": "CLIENT_ID",
"first_name": "Jane",
"last_name": "Doe",
"email": "[email protected]"
},
"workspace": {
"id": "WORKSPACE_ID",
"slug": "acme-coffee",
"name": "Acme Coffee",
"default_locale": "en"
},
"portal": {
"workspace_id": "WORKSPACE_ID",
"workspace_name": "Acme Coffee",
"workspace_slug": "acme-coffee",
"is_enabled": true,
"logo_url": "https://api.wallethero.app/assets/LOGO_FILE_ID",
"primary_color": "#1a1a1a",
"show_tier": true,
"show_rewards": true,
"show_redemption_history": true,
"show_referral": true
}
}
}Error Responses
| Status | Body | Cause |
|---|---|---|
400 | { "error": "workspace_slug and client_id are required" } | Missing/invalid input |
404 | { "error": "Workspace not found" } | No workspace for that slug |
404 | { "error": "not_found" } | Client not in that workspace, or not active (deliberately indistinguishable so client ids can't be enumerated) |
404 | { "error": "Loyalty portal not enabled" } | Portal disabled |
SDK
const session = await portal.createSession("acme-coffee", "CLIENT_ID");
// portal now stores session.token and uses it for subsequent callsGet Current Client Profile
GET /wallethero-api/public/loyalty/me
Returns the authenticated client's basic profile and custom fields.
Auth: Client session token (x-client-token header)
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/me" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": {
"id": "CLIENT_ID",
"first_name": "Jane",
"last_name": "Doe",
"email": "[email protected]",
"custom_fields": { "birthday": "1990-05-01" },
"workspace_id": "WORKSPACE_ID",
"marketing_consent": true
}
}SDK
const me = await portal.getMe();Set Marketing Consent
POST /wallethero-api/public/loyalty/marketing-consent
Self-serve marketing opt-in/opt-out for the authenticated client (GDPR consent withdrawal). Updates the client's marketing_consent flag and stamps consent provenance (marketing_consent_at, marketing_consent_source: "portal"). Re-asserting the current value is a no-op so the original consent timestamp is preserved.
Auth: Client session token (x-client-token header)
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
consent | boolean | Yes | true to opt in, false to opt out. |
Example Request
curl -X POST "https://api.wallethero.app/wallethero-api/public/loyalty/marketing-consent" \
-H "x-client-token: SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "consent": false }'Response (200)
{
"data": { "marketing_consent": false }
}SDK
const result = await portal.setMarketingConsent(false);Erase Account (self-serve GDPR erasure)
POST /wallethero-api/public/loyalty/erase
Irreversibly anonymizes the authenticated client (right to be forgotten): nulls all PII scalars (name, email, phone, birth date), wipes custom_fields, records marketing consent as withdrawn, disables loyalty-program eligibility, scrubs PII from the client event trail, sets status: "anonymized", and queues wallet pass updates so the pass stops showing personal data.
Before erasing, the client must re-assert a known identity factor in the request body:
- If the client has an email on file,
emailmust match it (trimmed, case-insensitive). - Otherwise, if the client has a phone on file,
phonemust match it (spaces and dashes ignored). - If neither is stored (POS/IDPoS imports, QR-only enrollments, an already partially scrubbed record), there is no factor to re-assert: send
confirm_erase: trueinstead. The session token is itself the proof of account control, and the request would otherwise be impossible to fulfil.
A mismatch returns a deliberately generic verification_failed — the response never reveals which factor was expected.
Once anonymized, the client can no longer mint a portal session, and previously issued client tokens stop authorizing requests immediately. Both cases return the same generic 404 { "error": "not_found" } as for unknown clients.
Rate limited to 5 requests per minute per IP (429 with Retry-After beyond that).
Auth: Client session token (x-client-token header)
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | If an email is on file | Must match the stored email (trimmed, case-insensitive) |
phone | string | If no email but a phone is on file | Must match the stored phone (spaces/dashes stripped) |
confirm_erase | boolean | If neither an email nor a phone is on file | Must be true. Ignored when the client holds either factor |
Example Request
curl -X POST "https://api.wallethero.app/wallethero-api/public/loyalty/erase" \
-H "x-client-token: SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'Response (200)
{
"data": { "success": true }
}Error Responses
| Status | Body | Cause |
|---|---|---|
400 | { "error": "verification_failed" } | Provided factor does not match, or the client holds no factor and confirm_erase was not true (generic on purpose) |
404 | { "error": "Client not found" } | Token's client no longer exists in that workspace |
429 | { "success": false, "error": "Too many requests, please try again later." } | Rate limit exceeded |
SDK
const result = await portal.eraseAccount({ email: "[email protected]" });
// Client with neither email nor phone on file:
// await portal.eraseAccount({ confirm_erase: true });
// result.success === trueList Available Rewards
GET /wallethero-api/public/loyalty/rewards
Returns the rewards available to the authenticated client, including affordability flags and lock reasons.
Auth: Client session token
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/rewards" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": [
{
"id": "r1000000-0000-0000-0000-000000000001",
"name": "Free coffee",
"description": "One free drip coffee",
"image_url": "https://api.wallethero.app/assets/IMAGE_FILE_ID",
"cost_points": 100,
"can_afford": true,
"is_locked": false,
"reason": null
}
]
}SDK
const rewards = await portal.listRewards();Get Reward Detail
GET /wallethero-api/public/loyalty/rewards/:id
Returns a single reward's full detail. The reward must belong to the session's workspace, otherwise an error is returned.
Auth: Client session token
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | Reward identifier |
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/rewards/REWARD_ID" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": {
"id": "REWARD_ID",
"workspace_id": "WORKSPACE_ID",
"name": "Free coffee",
"description": "One free drip coffee",
"cost_in_points": 100
}
}SDK
const reward = await portal.getReward("REWARD_ID");Redeem Reward (self-serve)
POST /wallethero-api/public/loyalty/rewards/:id/redeem
Redeems a reward on behalf of the authenticated client (source: "self_serve"). The reward and client must both live in the session's workspace.
Auth: Client session token
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string (UUID) | Reward identifier to redeem |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
metadata | object | No | Arbitrary metadata stored on the redemption |
Example Request
curl -X POST "https://api.wallethero.app/wallethero-api/public/loyalty/rewards/REWARD_ID/redeem" \
-H "x-client-token: SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "metadata": { "channel": "portal" } }'Response (200)
{
"data": {
"redemption_id": "rd000000-0000-0000-0000-000000000001",
"status": "approved",
"reward_name": "Free coffee",
"coupon_code": "CFE-9F2K",
"coupon_status": "issued"
}
}SDK
const result = await portal.redeem("REWARD_ID", { channel: "portal" });Get Wallet Balance
GET /wallethero-api/public/loyalty/balance
Returns the authenticated client's point balances per wallet type. Optionally filters to a single wallet type by code.
Auth: Client session token
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
wallet_type_code | string | No | Restrict to a single wallet type by its code |
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/balance" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": [
{
"wallet_type_id": "wt000000-0000-0000-0000-000000000001",
"wallet_type_code": "points",
"wallet_type_name": "Points",
"active_balance": 350,
"pending_balance": 20
}
]
}SDK
const balances = await portal.getBalance();
// or filter: await portal.getBalance("points");Get Redemption History
GET /wallethero-api/public/loyalty/redemptions
Returns the authenticated client's redemption history, paginated, enriched with reward names.
Auth: Client session token
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
limit | number | No | 20 | Max items to return |
offset | number | No | 0 | Number of items to skip |
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/redemptions?limit=10" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": [
{
"id": "rd000000-0000-0000-0000-000000000001",
"reward_id": "REWARD_ID",
"reward_name": "Free coffee",
"cost_points": 100,
"status": "approved",
"redeemed_at": "2026-03-01T12:00:00.000Z",
"coupon_code": "CFE-9F2K",
"coupon_status": "used",
"used_at": "2026-03-02T09:15:00.000Z"
}
],
"meta": { "total": 1, "limit": 10, "offset": 0 }
}SDK
const page = await portal.listRedemptions({ limit: 10 });
// page.data, page.metaGet Tier Status
GET /wallethero-api/public/loyalty/tier
Returns the workspace's active tier set and the authenticated client's progress within it. Both are null when no tier set is configured.
Auth: Client session token
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/tier" \
-H "x-client-token: SESSION_TOKEN"Response (200)
{
"data": {
"tier_set": { "id": "ts000000-0000-0000-0000-000000000001", "name": "Membership" },
"progress": {
"current_tier": { "id": "t1", "name": "Silver", "sort_order": 1 },
"next_tier": { "id": "t2", "name": "Gold", "sort_order": 2 },
"progress_to_next": 0.6,
"metric_value": 300,
"metric_target": 500
}
}
}SDK
const tier = await portal.getTier();Get Referral Info
GET /wallethero-api/public/loyalty/referral
Returns the authenticated client's own referral code, share link and stats. When the workspace referral program is disabled, returns { "enabled": false }.
Auth: Client session token
Example Request
curl "https://api.wallethero.app/wallethero-api/public/loyalty/referral" \
-H "x-client-token: SESSION_TOKEN"Response (200) — program enabled
{
"data": {
"enabled": true,
"completion_criteria": "first_transaction",
"code": "K7P2QX9M",
"share_url": "https://app.wallethero.com/distribution/public/loyalty-signup?ref=K7P2QX9M",
"stats": { "pending": 1, "completed": 3, "expired": 0 }
}
}Response (200) — program disabled
{ "data": { "enabled": false } }SDK
const referral = await portal.getReferralInfo();