LesyLoyalty API v1

Connect an ERP, a back-office system or your own database to your loyalty program: read members and balances, adjust points, send sales recorded outside Shopify (wholesale, phone or event orders), redeem rewards and check gift card balances. Webhooks notify your system whenever something changes. In-store sales go through Shopify POS, where LesyLoyalty is built in.

Base URL: https://app.lesyloyalty.com/api/v1 · JSON in, JSON out · Available on the Pro plan.

Authentication

Create a key in the LesyLoyalty app: Configuration › API keys. Each key has its own permissions and can be revoked at any time. Send it in the Authorization header:

curl https://app.lesyloyalty.com/api/v1/rewards \
  -H "Authorization: Bearer llk_your_key"
PermissionAllows
readFind members, transactions, rewards
pointsAdd or remove points
ordersSend, read, refund and cancel external sales
redeemRedeem rewards for a member
giftcardsNo longer used: gift cards are read-only through the API (reading them needs read)

Keep keys on your server. Never put them in a web page or a mobile app: anyone holding a key can act on your program.

Errors

Errors use standard HTTP status codes and always have the same shape:

{ "error": { "code": "insufficient_points", "message": "Not enough points", "balance": 120 } }
StatusCodes
400invalid_json, invalid_customer_id, invalid_points, invalid_line, lines_required, customer_required…
401unauthorized — missing, invalid or revoked key
403plan_required, insufficient_scope, member_excluded
404customer_not_found, not_member, order_not_found, reward_not_found
409insufficient_points, ambiguous_customer, insufficient_balance, gift_card_unusable, ambiguous_gift_card, code_taken, request_in_progress
422customer_create_failed
429rate_limited — see Retry-After
5xxserver_error, shopify_unavailable — safe to retry

Rate limits

120 requests per minute per key. Above that, the API answers 429 with a Retry-After header (seconds).

Duplicates

Retries are safe. A sale is identified by your external_id, a refund by your refund_id: sending them again never credits or removes points twice (the response says "status": "duplicate"). For point adjustments, send an idempotency_key. Gift card operations that change money require one.

Find a member

GET/members?customer_id=… | email=… | phone=…

Members are Shopify customers. Search by numeric Shopify customer ID, email or phone (E.164, e.g. +15145550123). Optional locale (e.g. fr, en) for tier and reward names. Permission: read.

curl "https://app.lesyloyalty.com/api/v1/members?email=jane@example.com" \
  -H "Authorization: Bearer llk_your_key"
{
  "member": {
    "customer_id": "7712345678901",
    "email": "jane@example.com",
    "first_name": "Jane",
    "balance": 1250,
    "lifetime_earned": 3400,
    "excluded": false,
    "tier": { "id": "cm…", "name": "Gold", "multiplier": 1.5, "spent_this_period": 812.4 },
    "birthday": "1990-03-07",
    "referral_code": "FRIEND-JANE1",
    "enrolled_at": "2026-05-02T14:21:09.000Z",
    "codes": [
      { "code": "REWARD-KC5MGH43", "reward": "$10 off", "points_spent": 1000,
        "expires_at": "2027-05-02T14:21:09.000Z", "created_at": "2026-05-02T14:21:09.000Z" }
    ]
  }
}

404 not_member means the customer exists in Shopify but has not joined the program yet; any point adjustment, sale or redemption enrolls them automatically.

Transactions

GET/members/{customer_id}/transactions?limit=50&before=…

Most recent first, up to 250 per page. To get the next page, pass next_before as before. Permission: read.

{
  "transactions": [
    { "id": "cm…", "created_at": "2026-10-20T18:44:38.777Z", "type": "purchase", "delta": 240,
      "balance_after": 1250, "description": "Order #1042", "reference": "5551234567890" }
  ],
  "has_more": true,
  "next_before": "2026-09-30T12:00:00.000Z"
}

Types: purchase, refund, cancel, signup, birthday, referral, social, review, redeem, redeem_cancel, manual, expire, adjustment.

Adjust points

POST/members/{customer_id}/points

Positive to add, negative to remove. Permission: points.

curl -X POST https://app.lesyloyalty.com/api/v1/members/7712345678901/points \
  -H "Authorization: Bearer llk_your_key" -H "Content-Type: application/json" \
  -d '{ "points": 500, "reason": "Workshop attendance", "idempotency_key": "workshop-2026-10-21-jane" }'
Field
pointsInteger, not zero, between -1,000,000 and 1,000,000
reason1 to 120 characters, shown in the member's history
idempotency_keyOptional, 1 to 64 characters: the same key is only applied once
{ "applied": 500, "member": { "customer_id": "7712345678901", "balance": 1750, … } }

Removing more points than the balance returns 409 insufficient_points unless negative balances are enabled in the program settings.

List rewards

GET/rewards?locale=en

Active rewards, in display order. Permission: read.

{ "rewards": [
  { "id": "cm…", "type": "fixed_amount", "label": "$10 off", "points_cost": 1000,
    "value": 10, "min_order_amount": null, "variant_id": null }
] }

Redeem a reward

POST/members/{customer_id}/redeem

Spends the points and creates the discount code (or gift card) in Shopify, exactly like a redemption from the store widget. Permission: redeem.

curl -X POST https://app.lesyloyalty.com/api/v1/members/7712345678901/redeem \
  -H "Authorization: Bearer llk_your_key" -H "Content-Type: application/json" \
  -d '{ "reward_id": "cm…", "locale": "en" }'
{ "code": "REWARD-8Q2WXT7M", "label": "$10 off", "expires_at": "2027-10-21T13:00:00.000Z",
  "variant_id": null, "member": { "balance": 750, … } }

Send a sale

POST/orders

For sales recorded outside Shopify in an ERP or back-office system (wholesale, phone or event orders). Points are calculated with the same rules as Shopify orders: earning rate, tier multiplier, active points promotions, excluded tags, gift cards. Reward codes listed in discount_codes are marked as used. Permission: orders.

curl -X POST https://app.lesyloyalty.com/api/v1/orders \
  -H "Authorization: Bearer llk_your_key" -H "Content-Type: application/json" \
  -d '{
    "external_id": "ERP-000123",
    "name": "Store #123",
    "customer": { "email": "jane@example.com", "first_name": "Jane", "create": true },
    "lines": [
      { "title": "Candle 8 oz", "quantity": 2, "price": 24.00, "discount": 4.00, "product_id": 8123456789012 },
      { "title": "Room mist", "quantity": 1, "price": 18.00 }
    ],
    "discount_codes": ["REWARD-8Q2WXT7M"]
  }'
Field
external_idRequired. Your unique sale ID: 1 to 64 characters (letters, digits, . _ : -)
customerRequired. customer_id, email or phone. With "create": true (and optional first_name, last_name) the Shopify customer is created if not found.
linestitle, quantity, price (unit price), discount (total for the line), optional product_id / variant_id (Shopify IDs, needed for product or collection promotions), sku, gift_card. Up to 500 lines.
amountInstead of lines: products total after discounts, before taxes and shipping
name, discount_codes, tags, localeOptional

Amounts are in the store currency, products only (no taxes, no shipping).

{ "status": "credited", "external_id": "ERP-000123", "points": 112,
  "customer_id": "7712345678901", "member": { "balance": 1362, … } }

status: credited (201), duplicate (already sent) or skipped with a reason (order_tag, customer_tag, member_excluded, b2b_company).

Sale status

GET/orders/{external_id}
{ "order": { "external_id": "ERP-000123", "name": "Store #123", "customer_id": "7712345678901",
  "base_amount": 62, "refunded_amount": 0, "points_credited": 112, "points_removed": 0,
  "cancelled": false, "tier": "Gold", "campaigns": [], "created_at": "2026-10-21T13:00:00.000Z" } }

Refund

POST/orders/{external_id}/refunds

Removes points in proportion to the refunded amount (products, before taxes). Permission: orders.

curl -X POST https://app.lesyloyalty.com/api/v1/orders/ERP-000123/refunds \
  -H "Authorization: Bearer llk_your_key" -H "Content-Type: application/json" \
  -d '{ "refund_id": "RF-77", "amount": 18.00 }'
{ "status": "refunded", "points": -33 }

Cancel

POST/orders/{external_id}/cancel

Removes all the points still credited for the sale. Permission: orders.

{ "status": "cancelled", "points": -79 }

Gift cards

Gift cards are Shopify gift cards, the same cards customers use on your online store and in Shopify POS, with one shared balance. Through the API they are read-only (permission read): find a card and check its balance. Cards are sold, topped up, charged and deactivated in Shopify (Shopify POS or the Shopify admin), and refunds always go back to the original payment method in Shopify. Write calls (POST /giftcards, /debit, /credit, /deactivate) return 410 gift_cards_read_only. Amounts are in the card currency (your store currency).

{
  "gift_card": {
    "id": "584736251",
    "last_characters": "7K2Q",
    "balance": 42.5,
    "initial_value": 50,
    "currency": "CAD",
    "enabled": true,
    "expired": false,
    "expires_on": null,
    "customer_id": "7712345678901",
    "note": "Loyalty reward",
    "created_at": "2026-10-21T13:00:00Z"
  }
}

Find a card

GET/giftcards?code=… | last_characters=… | customer_id=… | email=… | phone=…
GET/giftcards/{id}
curl "https://app.lesyloyalty.com/api/v1/giftcards?code=X7HG2MPQ9R4W7K2Q" \
  -H "Authorization: Bearer llk_your_key"
{ "gift_cards": [ { "id": "584736251", "last_characters": "7K2Q", "balance": 42.5, "enabled": true, … } ] }

By customer, deactivated cards are hidden unless you add include_disabled=true. An empty list means no card matches.

Webhooks

Add up to 5 HTTPS endpoints in Configuration › API keys › Outgoing webhooks and choose their events. LesyLoyalty sends a POST with a JSON body:

{
  "id": "6f1c2c1e-7a0e-4d1f-9a3e-0e3b5d2a9c11",
  "event": "points.changed",
  "created_at": "2026-10-21T13:00:02.114Z",
  "data": { … }
}
Header
X-LesyLoyalty-EventEvent name
X-LesyLoyalty-DeliveryDelivery ID (the same on retries)
X-LesyLoyalty-TimestampUnix time (seconds) of this attempt
X-LesyLoyalty-Signaturesha256= + HMAC-SHA256 of timestamp + "." + raw body with the endpoint secret

Answer with any 2xx within 10 seconds. Otherwise the delivery is retried 5 times: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Events can arrive more than once or out of order: use the body id to ignore duplicates.

Verify signatures

Always verify the signature with the raw body (before JSON parsing) and reject timestamps older than 5 minutes.

Node.js

const crypto = require("crypto");

function verify(rawBody, headers, secret) {
  const ts = headers["x-lesyloyalty-timestamp"];
  const sig = headers["x-lesyloyalty-signature"] || "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
  return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers["X-LesyLoyalty-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(headers.get("X-LesyLoyalty-Signature", ""), expected)

PHP

$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_LESYLOYALTY_TIMESTAMP'];
$sig = $_SERVER['HTTP_X_LESYLOYALTY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
$valid = abs(time() - (int) $ts) <= 300 && hash_equals($expected, $sig);

Events

Eventdata
points.changedcustomer_id, transaction_id, delta, balance, lifetime_earned, type, description, reference
tier.changedcustomer_id, previous_tier (id, name or null), tier (id, name, multiplier), direction (up | down)
reward.redeemedcustomer_id, reward_id, label, code, expires_at, balance
member.createdcustomer_id, email, first_name, balance, enrolled_at
giftcard.createdCard created through LesyLoyalty (Shopify POS tile, reward redeemed for a gift card). gift_card_id, last_characters, balance, initial_value, currency, enabled, expires_on, customer_id, order_id (Shopify order that sold it, or null), updated_at
giftcard.updatedSame fields, current state after the change: balance used, topped up or refunded, card deactivated (enabled: false, disabled_at) or expiry changed
pingSent by the « Send test » button: message

Every change of points sends points.changed, whatever its origin: Shopify orders, the store widget, Shopify POS, the admin or this API.