Skip to content

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:

  1. Fully public (no token):
    • GET /public/loyalty/portal-config — branding/config for a workspace, keyed by workspace_slug.
    • POST /public/loyalty/session — exchanges a (workspace_slug, client_id) pair for a session token. This is how a portal starts.
  2. Session-token authed: every other endpoint requires the token issued by /session, passed via the x-client-token request header (query-string tokens are no longer accepted — they leak into access logs and browser history). The token is a JWT of type client_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 a portal instance 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

ParameterTypeRequiredDescription
workspace_slugstringYesWorkspace slug whose portal config to fetch

Example Request

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/portal-config?workspace_slug=acme-coffee"

Response (200)

json
{
  "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)

json
{ "error": "Loyalty portal not found" }

SDK

typescript
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

FieldTypeRequiredDescription
workspace_slugstringYesSlug of the workspace the client belongs to
client_idstring (UUID)YesThe client opening the portal

Example Request

bash
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)

json
{
  "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

StatusBodyCause
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

typescript
const session = await portal.createSession("acme-coffee", "CLIENT_ID");
// portal now stores session.token and uses it for subsequent calls

Get 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

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/me" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "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

typescript
const me = await portal.getMe();

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

FieldTypeRequiredDescription
consentbooleanYestrue to opt in, false to opt out.

Example Request

bash
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)

json
{
  "data": { "marketing_consent": false }
}

SDK

typescript
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, email must match it (trimmed, case-insensitive).
  • Otherwise, if the client has a phone on file, phone must 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: true instead. 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

FieldTypeRequiredDescription
emailstringIf an email is on fileMust match the stored email (trimmed, case-insensitive)
phonestringIf no email but a phone is on fileMust match the stored phone (spaces/dashes stripped)
confirm_erasebooleanIf neither an email nor a phone is on fileMust be true. Ignored when the client holds either factor

Example Request

bash
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)

json
{
  "data": { "success": true }
}

Error Responses

StatusBodyCause
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

typescript
const result = await portal.eraseAccount({ email: "[email protected]" });
// Client with neither email nor phone on file:
// await portal.eraseAccount({ confirm_erase: true });
// result.success === true

List 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

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/rewards" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "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

typescript
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

ParameterTypeDescription
idstring (UUID)Reward identifier

Example Request

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/rewards/REWARD_ID" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "data": {
    "id": "REWARD_ID",
    "workspace_id": "WORKSPACE_ID",
    "name": "Free coffee",
    "description": "One free drip coffee",
    "cost_in_points": 100
  }
}

SDK

typescript
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

ParameterTypeDescription
idstring (UUID)Reward identifier to redeem

Request Body

FieldTypeRequiredDescription
metadataobjectNoArbitrary metadata stored on the redemption

Example Request

bash
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)

json
{
  "data": {
    "redemption_id": "rd000000-0000-0000-0000-000000000001",
    "status": "approved",
    "reward_name": "Free coffee",
    "coupon_code": "CFE-9F2K",
    "coupon_status": "issued"
  }
}

SDK

typescript
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

ParameterTypeRequiredDescription
wallet_type_codestringNoRestrict to a single wallet type by its code

Example Request

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/balance" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "data": [
    {
      "wallet_type_id": "wt000000-0000-0000-0000-000000000001",
      "wallet_type_code": "points",
      "wallet_type_name": "Points",
      "active_balance": 350,
      "pending_balance": 20
    }
  ]
}

SDK

typescript
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

ParameterTypeRequiredDefaultDescription
limitnumberNo20Max items to return
offsetnumberNo0Number of items to skip

Example Request

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/redemptions?limit=10" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "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

typescript
const page = await portal.listRedemptions({ limit: 10 });
// page.data, page.meta

Get 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

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/tier" \
  -H "x-client-token: SESSION_TOKEN"

Response (200)

json
{
  "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

typescript
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

bash
curl "https://api.wallethero.app/wallethero-api/public/loyalty/referral" \
  -H "x-client-token: SESSION_TOKEN"

Response (200) — program enabled

json
{
  "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

json
{ "data": { "enabled": false } }

SDK

typescript
const referral = await portal.getReferralInfo();

WalletHero Documentation