Give loyalty from your POS

The cashier rings up the sale as usual; your POS adds the stamps or points to the customer's ServeU card. The key needs Give stamps and points (loyalty:write, which includes looking cards up). Give each branch's POS its own key limited to that branch.

1. Find the customer

By phone (Saudi local format or E.164; other countries' E.164 numbers work too):

curl -X POST https://serveu-psi.vercel.app/api/v1/cards/lookup -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -d '{"phone":"0551234567"}'

Or scan the customer's card QR and send what the scanner reads (the 24-digit card number or the card page URL) as card_token: {"card_token":"<scanned text>"}. Send exactly one of the two.

The answer is the card. Keep its id and read program.type: stamps or points.

2. Not found? Offer to join

404 not_found means this phone has no card here yet. With the customer's OK, join them:

curl -X POST https://serveu-psi.vercel.app/api/v1/cards -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -d '{"phone":"0551234567","name":"Noura"}'

The answer is always 201 with { "existing": …, "card": … }: existing: true means the customer already had a card (say, they joined a moment ago from a poster), and you get that card. The new card is unverified until the customer confirms their phone.

3. After payment, add stamps or points

Stamps program: send stamps. Points program: send bill_amount in halalas (100 = 1.00 SAR) and ServeU works out the points.

Only for sales rung up on your POS. ServeU menu orders already earn: when such an order is served (on the iPad or with the API's serve action) its stamps or points are added automatically, so don't call /earn for it too, or the customer gets them twice.
curl -X POST https://serveu-psi.vercel.app/api/v1/cards/<card id>/earn -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: receipt-10422" \
  -d '{"stamps":1,"reference":"R-10422"}'

# a points program:
curl -X POST https://serveu-psi.vercel.app/api/v1/cards/<card id>/earn -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: receipt-10423" \
  -d '{"bill_amount":4400,"reference":"R-10423"}'
  • reference is your receipt number; it shows in the card's history.
  • Idempotency-Key: receipt-<number> makes a retry safe (see Idempotency).
  • The answer (201) has the event (keep its id for refunds) and the updated card. capped: true means the per-visit cap reduced the points.
  • Print card.balance and card.progress on the receipt (stamps_to_next or points_to_next, and rewards_available).

4. Redeem a reward

Stamps program: redeem one full card. The body has nothing required, but it must still be JSON: send {} or just a reference.

curl -X POST https://serveu-psi.vercel.app/api/v1/cards/<card id>/redeem -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: redeem-R-10424" \
  -d '{"reference":"R-10424"}'

Points program: send a reward_id from card.rewards (the ones the customer can afford now are in card.progress.affordable_reward_ids):

curl -X POST https://serveu-psi.vercel.app/api/v1/cards/<card id>/redeem -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: redeem-R-10425" \
  -d '{"reward_id":"<reward id>","reference":"R-10425"}'

5. Refund or mistake: undo

curl -X POST https://serveu-psi.vercel.app/api/v1/loyalty-events/<event id>/void -H "Authorization: Bearer serveu_live_XXXX…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: void-R-10422" \
  -d '{"reason":"Refund of R-10422"}'

The event stays in the card's history, struck through, and the balance goes back. If a retry answers 409 already_voided, the first attempt worked: treat it as success.

Answers to show the cashier

These are normal outcomes, not bugs:

  1. card_cap_reached: more stamps than the program allows per visit.
  2. below_min_bill: the bill is under the points program's minimum.
  3. not_enough_balance: the customer can't afford that reward yet.
  4. no_program: the business has no active loyalty program.

Each of these answers is stored with its Idempotency-Key for 24 hours. If the cashier corrects the sale (fewer stamps, another reward) or the owner sets up a program, send the corrected request with a new key, e.g. receipt-10422-2: the old key would answer the stored error again (or 409 idempotency_conflict if the body changed).

A key limited to one branch earns and redeems at that branch. A key for all branches can send branch_id so the visit is counted at the right branch.

Optional: keep the POS up to date with webhooks

If the POS shows balances or keeps its own copy of the cards, subscribe a webhook endpoint to loyalty.earned, loyalty.redeemed, loyalty.voided, loyalty.adjusted and card.reward_ready instead of looking every card up again.

  • data.object is the card after the change (its balance and progress), and data.event the ledger entry, with its source: order for a ServeU menu order, counter for staff on the iPad, dashboard for an owner's correction, switch for a program change, api for a call with a key.
  • Your POS's own /earn calls come back as loyalty.earned too. Use these events only to update what you show: never call /earn because of one, or every visit would earn again and again.
  • Events can arrive out of order and cards have no updated_at: if the exact balance matters right now, read the card with GET /api/v1/cards/{id}.
  • card.reward_ready is the moment to tell the cashier "this customer has a free coffee".

Next: Sync your menu →