Rewards API

Issue and redeem loyalty points from your app. One integration powers points across all your merchants and channels.

Overview

The Rewards service is a central loyalty backend. Your apps call it server-to-server; your merchants and members never talk to it directly — they use your app, which calls this API on their behalf.

Each app you connect is a “channel” (e.g. dealerden, ych-hub) under your tenant. Members are identified globally by their horizonId, so the same person is one member everywhere. A merchant is identified by a stable merchant key, so a merchant’s shops in different apps resolve to one merchant and a buyer’s points pool across your apps.

Core concepts

  • Member — a person, keyed by horizonId. Auto-created on first use. Pass an optional displayName.
  • Merchant — keyed by a stable key you choose (e.g. the merchant’s horizonId). Pass the same key from every app and their shops pool into one merchant. The per-app shop id goes in externalId (recorded as a link).
  • Points have two scopesmerchant points (earned at one merchant) and tenant points (usable across all your merchants). An earn credits both.
  • Earning rules — configured in the dashboard. When you send a spend amount, the service computes points from your rules.
  • Rewards — a catalog you define in the dashboard; members redeem points for a voucher. You can also do raw point debits for app-defined perks.

Authentication

Every request sends an API key in the x-api-key header. Create one key per app in your tenant dashboard under Integration → New API key (the secret is shown once). Keys are channel-tagged and individually revocable.

x-api-key: rwd_dealerden_xxxxxxxxxxxxxxxxxxxxxxxx

Base URL: https://reward.horizonden.dev/api/v1

Keys can be scoped at creation (least privilege): earn, redeem, debit, subscribe, read. A call outside the key's scopes returns 403. Provisioning endpoints (/members/resolve, /merchants/upsert) require the earn scope.

Conventions

  • JSON in, JSON out. Success: { "ok": true, "data": … }. Error: { "ok": false, "error": "…" }.
  • Mutating calls take an idempotencyKey (unique per tenant). Retries with the same key never double-apply.
  • Money is sent as integer minor units (amountCents) plus a 3-letter ISO currency.
  • Auth failures return 401; business errors (insufficient balance, out of stock) return 409.
  • Each key has a per-minute rate limit (default 300). Over the limit returns 429 — back off and retry.
  • While a tenant is suspended, mutating endpoints return 403; reads keep working.

Earn points

POST/api/v1/earn

Credit points to a member at a merchant — both merchant and tenant balances. Auto-provisions the member and merchant. Two modes:

Explicit points

curl -X POST https://reward.horizonden.dev/api/v1/earn \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "horizonId": "hzn_buyer_1",
    "displayName": "Ada L.",
    "merchant": { "key": "hzn_merch_coffee", "externalId": "shop-42", "name": "Corner Coffee" },
    "mode": "points",
    "points": 30,
    "idempotencyKey": "order-1001"
  }'

From a spend amount (uses your earning rules)

curl -X POST https://reward.horizonden.dev/api/v1/earn \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "horizonId": "hzn_buyer_1",
    "merchant": { "key": "hzn_merch_coffee", "externalId": "shop-42" },
    "mode": "spend",
    "amountCents": 500,
    "currency": "USD",
    "idempotencyKey": "order-1002"
  }'

Response

{
  "ok": true,
  "data": {
    "memberId": "…",
    "merchantId": "…",
    "credited": { "merchant": 30, "tenant": 30 },
    "balances": {
      "tenant": 30,
      "merchants": [{ "merchantId": "…", "name": "Corner Coffee", "balance": 30 }]
    }
  }
}

Get balances

GET/api/v1/members/{horizonId}/balances

A member’s tenant-wide balance plus every per-merchant balance. Returns zeros if the member has no activity yet.

curl https://reward.horizonden.dev/api/v1/members/hzn_buyer_1/balances -H "x-api-key: $KEY"

Resolve a member

POST/api/v1/members/resolve

Ensure a member exists (idempotent) and read their balances. Useful before showing a loyalty UI.

curl -X POST https://reward.horizonden.dev/api/v1/members/resolve \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{ "horizonId": "hzn_buyer_1", "displayName": "Ada L." }'

Upsert a merchant / link a shop

POST/api/v1/merchants/upsert

Create a merchant (by stable key) and link this app’s shop id. Optional — earn does this automatically — but useful to pre-register a merchant or attach a second shop.

curl -X POST https://reward.horizonden.dev/api/v1/merchants/upsert \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{ "key": "hzn_merch_coffee", "externalId": "shop-42", "name": "Corner Coffee", "url": "https://…" }'

Redeem a reward

POST/api/v1/redeem

Redeem a catalog reward for a member. Debits the matching scope’s points and returns a voucher code. Fails with 409 if the balance is too low or stock is exhausted. Send an idempotencyKey — a retried request returns the original redemption (with deduped: true) instead of double-spending.

curl -X POST https://reward.horizonden.dev/api/v1/redeem \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{ "horizonId": "hzn_buyer_1", "rewardId": "<reward-id from /rewards>", "idempotencyKey": "redeem-9001" }'
{ "ok": true, "data": { "redemptionId": "…", "voucherCode": "LEG2MITL", "balances": { … } } }

Raw debit

POST/api/v1/debit

Spend points without the catalog — for app-defined perks. Choose tenant or merchant scope (merchant scope needs merchantKey).

curl -X POST https://reward.horizonden.dev/api/v1/debit \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "horizonId": "hzn_buyer_1",
    "scope": "merchant",
    "merchantKey": "hzn_merch_coffee",
    "points": 100,
    "reference": "free-shipping-coupon",
    "idempotencyKey": "perk-7781"
  }'

List rewards

GET/api/v1/rewards

Active rewards for your tenant (tenant-wide and per-merchant). Each includes scope, merchantId, costPoints, and remaining stock (or null = unlimited).

curl https://reward.horizonden.dev/api/v1/rewards -H "x-api-key: $KEY"

Subscribe a member

POST/api/v1/subscribe

Start a member’s paid subscription and get a Stripe Checkout URL to redirect them to. Two kinds:

  • Member → tenant (a tenant-wide tier, e.g. “Premium” — unlocks features across all the tenant’s apps): omit merchantKey; pass a tenant tier planId.
  • Member → merchant: pass merchantKey + a merchant planId.

The charge is a direct charge on the seller’s connected account (must be onboarded) + the platform fee.

curl -X POST https://reward.horizonden.dev/api/v1/subscribe \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "horizonId": "hzn_user_1",
    "planId": "<tenant tier id>",
    "returnUrl": "https://yourapp.com/premium/done"
  }'   # member→tenant (omit merchantKey)
{ "ok": true, "data": { "checkoutUrl": "https://checkout.stripe.com/…", "kind": "member_to_tenant" } }

Check a member's entitlements

GET/api/v1/members/{horizonId}/entitlements

Gate features on a member’s tenant-wide subscription. One call tells your app whether the member is subscribed and what their tier unlocks — check features and read numeric limits.

curl https://reward.horizonden.dev/api/v1/members/hzn_user_1/entitlements -H "x-api-key: $KEY"
{
  "ok": true,
  "data": {
    "active": true,
    "tier": { "code": "premium", "name": "Premium" },
    "features": ["receipt-ocr", "unlimited-groups"],
    "limits": { "maxGroups": 50 },
    "status": "active",
    "currentPeriodEnd": 1788...
  }
}

Example gate: if (!data.active || !data.features.includes("receipt-ocr")) return paywall(). When the member has no active subscription, active is false and features is empty.

Errors

  • 401 — missing or invalid/revoked API key.
  • 400 — validation failed (the error string says which field).
  • 404 — reward not found.
  • 409insufficient_balance or out_of_stock.

Questions or need a key? Your tenant owner manages keys and earning rules in the app’s Integration and Earning rules sections.