Cardy docs

Quickstart — your first points in 15 minutes

By the end of this page you will have awarded points on a real sandbox card and seen them land. Everything here is copy-paste runnable.

What you need

Three things, all from us:

Your test key Looks like cardy_pk_test_…. It only ever reaches the sandbox — there is no way for a test key to touch live customer balances.
A test card Serial 947706-732-680, on a real sandbox customer. Your test card prints it as a QR your scanner can read.
A test customer The phone number that card belongs to: 96599901001. Same person, second way in — a customer who left their card at home still has to be servable.

The card is the one to start with. Your till identifies people by scanning, and the serial is what a scanner reads — so the scan path is the one worth getting right first. The phone is the fallback a cashier types.

The sandbox lives at:

https://sandbox.getcardy.net/api/v1

Every request needs your key in the Authorization header. Set it once:

export CARDY_KEY="cardy_pk_test_replace_me"
export CARDY_URL="https://sandbox.getcardy.net/api/v1"

1. Scan the card

This is what your till does the moment a customer presents their card. Send whatever the scanner read, as loyalty_id:

curl -s -X POST "$CARDY_URL/customers/identify" -H "Authorization: Bearer $CARDY_KEY" -H "Content-Type: application/json" -d '{"loyalty_id":"947706-732-680"}'

There is nothing to parse and no prefix to strip: the serial on the card is the value we resolve. It is also the card_id you will see in the response — one string, whether it reached you from a scanner or from an earlier identify.

If the customer left the card at home, the same call takes their phone instead, and answers with the same customer:

curl -s -X POST "$CARDY_URL/customers/identify" -H "Authorization: Bearer $CARDY_KEY" -H "Content-Type: application/json" -d '{"phone":"96599901001"}'

Send exactly one identifier. Two would be ambiguous — if they named different people, either answer could award points to the wrong one — so we refuse rather than choose.

You get the customer and every card they hold:

{
  "customer_id": "01a03fca-ffa8-70b4-b850-428496841903",
  "cards": [
    {
      "card_id": "947706-732-680",
      "template_name": "Demo Restaurant (points per KWD)",
      "balances": { "points": 99, "stamps": null, "visits": null },
      "allowed_operations": ["earn_amount", "redeem_points", "redeem_reward"]
    }
  ]
}

allowed_operations is the field to build your buttons from. It lists what this card can actually do. If redeem_points is not in the list, do not show a redeem button — the card cannot do it, and the call would be refused.

Balances are null for dimensions a card does not have. A stamp card has no points; that is different from having zero points.

2. Award points for a sale

curl -s -X POST "$CARDY_URL/transactions/earn" -H "Authorization: Bearer $CARDY_KEY" -H "Content-Type: application/json" -d '{"identifier":{"loyalty_id":"947706-732-680"},"card_id":"947706-732-680","external_ref":"receipt-1001","amount_minor":12500,"currency":"KWD"}'
{ "transaction_id": "cmtc1kl6w0000qs01bt4prksp", "status": "pending", "duplicate": false }

Three fields deserve attention.

The scan appears twice, and it is not a typo. identifier says who this is; card_id says which card the points go on. They carry the same string because you scanned one card belonging to one person — but they answer different questions, and a customer holding four cards makes that obvious. Leave card_id out and we choose from everything they hold, which is right for a phone lookup and wrong for a scan: the cashier is holding the card the customer chose.

external_ref is your receipt number. Send your own — whatever identifies this sale in your system. It is how you look the transaction up later, how you refund it, and how we stop a retry from paying twice.

amount_minor is an integer. KWD has three decimals, so 12.500 KWD is 12500. Never send a decimal; never assume two places.

status: "pending" means we have accepted the sale and are applying it. The customer's balance updates within a second or two. Your till does not need to wait — the sale is already recorded.

3. Prove it landed

Look the sale up by the receipt number you sent:

curl -s "$CARDY_URL/transactions?external_ref=receipt-1001" -H "Authorization: Bearer $CARDY_KEY"
{
  "data": [
    {
      "transaction_id": "cmtc1kl6w0000qs01bt4prksp",
      "intent": "earn",
      "status": "success",
      "external_ref": "receipt-1001",
      "card_id": "144389-894-749",
      "amount_minor": 12500,
      "currency": "KWD",
      "points": null
    }
  ]
}

status has moved from pending to success — the sale has been applied.

For an end-of-day or accounting pull, ask for a window instead of a receipt. from is inclusive and to exclusive, so one call per day tiles cleanly, and channel tells you which rows your system sent and which your staff made in the Cardy dashboard:

curl -s "$CARDY_URL/transactions?from=2026-10-20T00:00:00%2B03:00&to=2026-10-21T00:00:00%2B03:00&limit=100" -H "Authorization: Bearer $CARDY_KEY"

Follow next_cursor until it comes back null.

card_id is the card you scanned, because you named it. Had you left it out, we would have picked from the customer's cards ourselves — and where more than one could genuinely take the sale, answered VALIDATION_ERROR with multiple_eligible_cards rather than guessing which balance to move.

points is null while amount_minor is set, because we record what you sent us. The card's own balance moved in whatever unit that card keeps — scan it again and you will see it.

4. Now retry it

Send the exact same earn request from step 2 a second time.

{ "transaction_id": "cmtc1kl6w0000qs01bt4prksp", "status": "success", "duplicate": true }

Same transaction, duplicate: true, and the customer was not paid twice.

This is the most important thing to understand about this API. Your till will lose connectivity mid-sale, and it will not know whether we got the request. The answer is always the same: send it again with the same external_ref. It cannot double-award. You never need to reconcile, ask us, or write logic to guess what happened.

The one way to break this is to generate a new external_ref on retry. Don't. The receipt number is the same receipt number.

Where to go next

  • Error handling — every code, what the cashier should see, and what your code should do.
  • Certification — what we check before you go live, and how to run those checks yourself.
  • Postman collection — every endpoint, ready to import.
Cardy Loyalty API Questions your integration cannot answer belong in an email, not a retry loop.