Importing loyalty history

The member import moves balances: a member who had 1,250 points on your old platform has 1,250 points here, as a single opening adjustment. That is the right default, and for most migrations it is the whole job.

It is not enough when you want Cascade to know what those points were made of. Rolling tier thresholds look at what a member earned in the last twelve months. Monthly active members counts the months something happened. Both of those start from zero on the day you switch unless you tell Cascade what came before. POST /loyalty-history is how you tell it.

There is no screen for this. It is an API because it is a migration tool, run once from a script that reads your old platform's export, not something a merchant does on a Tuesday afternoon.

What it writes

One call carries one member's history: up to 500 entries, each with the timestamp it happened at.

curl -X POST https://api.cascade.example.com/loyalty-history \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "customerEmail": "[email protected]",
      "entries": [
        {
          "externalId": "order-88213",
          "occurredAt": "2025-11-04T10:12:00Z",
          "amount": 900,
          "reason": "Order 88213"
        },
        {
          "externalId": "redemption-4471",
          "occurredAt": "2026-01-22T16:40:00Z",
          "amount": -400,
          "kind": "spend",
          "reason": "Redeemed a $10 coupon"
        }
      ]
    }
  }'
Response (200 OK)
{
  "customerId": "019fbc6e-0c93-7e52-ab88-bf34872a4430",
  "loyaltyProgramId": "019fbc6e-0cae-706c-a789-81362cbea182",
  "loyaltyMembershipId": "019fbc6e-0cc1-7a3e-9f57-2d1f8a4c9b10",
  "createdCount": 2,
  "duplicateCount": 0,
  "balance": 500,
  "loyaltyTierId": "019fbc6e-0cd4-71b8-8c22-6a5b3e0f7d91",
  "openingAdjustment": { "status": "none", "amount": null },
  "entries": [
    {
      "externalId": "order-88213",
      "status": "created",
      "loyaltyTransactionId": "019fbc6e-0ce2-7f41-b3d6-91c47a2e5b83",
      "occurredAt": "2025-11-04T10:12:00.000Z",
      "amount": 900,
      "balanceAfter": 900
    },
    {
      "externalId": "redemption-4471",
      "status": "created",
      "loyaltyTransactionId": "019fbc6e-0ce2-7f41-b3d6-91c47a2e5b84",
      "occurredAt": "2026-01-22T16:40:00.000Z",
      "amount": -400,
      "balanceAfter": 500
    }
  ]
}

The customer has to exist already, so import your customers first. They are enrolled in the program if they are not a member yet, and entries may predate the day they joined, which is normal: the history is older than the record of it.

Fields

FieldRequiredWhat it is
customerEmailYesThe customer the history belongs to, as Cascade knows them.
loyaltyProgramIdNoThe program. Leave it out when the organization has exactly one.
entriesYesUp to 500 entries. Any order.

Each entry:

FieldRequiredWhat it is
externalIdYesYour identifier for the entry, usually its primary key on the old platform.
occurredAtYesWhen it happened, as an ISO 8601 timestamp. Has to be in the past.
amountYesPoints moved: positive for an earn, negative for a spend. Never zero.
kindNoearn, spend or adjustment. Defaults to the sign of the amount.
reasonNoA short description, shown on the member's activity feed.

Order does not matter

Send the entries in whatever order your export produces them, across as many calls as you like. Each one is filed under its own occurredAt, and the member's running balance is put back in order before the call returns. Import six months of history today and the six months before it next week, and the feed reads correctly both times.

That is worth saying plainly because the ledger is otherwise append-only: every entry carries the balance immediately after it, and inserting something into the middle rewrites those totals for everything after it. Cascade does that for you, inside one transaction, so the balance a member sees is never briefly wrong.

Re-runs are free

externalId is required, and it is what makes a migration script safe to run twice. Send the same identifier for the same member again and nothing is written a second time. The entry comes back with "status": "duplicate":

{
  "createdCount": 0,
  "duplicateCount": 2,
  "balance": 500,
  "entries": [
    { "externalId": "order-88213", "status": "duplicate", "balanceAfter": 900, "...": "..." },
    { "externalId": "redemption-4471", "status": "duplicate", "balanceAfter": 500, "...": "..." }
  ]
}

Use the primary key from the platform you are leaving. It is stable, you already have it, and it means a failed script can be restarted from the top rather than from wherever it stopped.

Balances you already imported

Most migrations import balances first and history later. Doing both without any care would double a member's points: 500 from the opening adjustment, and another 500 from the history that produced it.

Cascade reconciles instead. The opening adjustment only ever stood in for history nobody had yet, so once the history arrives the opening shrinks by what the history explains, and goes away entirely when that is all of it:

{
  "createdCount": 2,
  "balance": 500,
  "openingAdjustment": { "status": "removed", "amount": null }
}

The member's balance does not move. It is still the number you declared, which is still the number you promised the customer. What changed is that it is now explained by real entries instead of one lump.

Partial history works the same way. Import the last twelve months of a member whose balance is 500, and if that history nets to 300, the opening is reduced to 200 and the balance is still 500:

{ "balance": 500, "openingAdjustment": { "status": "reduced", "amount": 200 } }

The other order is fine too. Import history first and then run the member import with the same balances, and it reconciles to the same place rather than adding an opening on top.

Tiers

Imported history counts toward tier thresholds, including rolling twelve-month ones, and the response returns the member's tier so you can check:

{ "loyaltyTierId": "019fbc6e-0cd4-71b8-8c22-6a5b3e0f7d91" }

An import can promote a member. It never demotes one: if the history you imported is thinner than what the member was placed on, they keep the tier they have and the periodic review takes it from there. That means you can import history for a member whose tier you already set from the member import's tier column without undoing your own work.

Points expiry

Importing old history does not expire anything retroactively. A program that expires points after 90 days does not wipe a balance the moment you import earns from two years ago, which would be a surprising way to lose a migration.

Imported points age from the day you import them instead. So with a 90-day policy, history imported today starts expiring in 90 days, exactly as if the member had earned it on switchover day. If you want a member's points to age on their original schedule, expire them on the old platform before you migrate and import what is left.

Errors

StatusWhat happened
400An entry is dated in the future, two share an externalId, or the body did not validate.
403The API key is missing, wrong, or belongs to another organization.
404The customer or the program is one this organization does not have.
409The history would leave the member's balance below zero.
429Over the rate limit.

The 409 is the one worth planning for. It means the file has spends in it whose earns are missing entirely:

{
  "error": {
    "code": "CONFLICT",
    "message": "This history would leave the balance at -300. Check that every spend in the file has the earn behind it, or import the member's opening balance first."
  }
}

A balance that dips below zero part way through the history is fine and is not an error. A member whose oldest earn is older than the window you exported will legitimately look overdrawn for a while. Only the balance at the end has to make sense.

Unknown customers are a 404 rather than something Cascade creates for you, because a migration script with a column offset in it would otherwise fill your customer list with addresses nobody can reach:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No customer in this organization has the email [email protected]. Import your customers before their loyalty history."
  }
}

Rate limits

A burst of 60 calls, refilling at one a second, per organization. Each call carries a whole member's history, so that is a comfortable pace for a migration script and there is no need to run it in parallel. Over the limit is a 429, and the right response is to back off and retry.

This endpoint also costs 10 tokens against the organization-wide admin API limit, since one call writes a whole member's history.

Webhooks

Imported entries do not fire loyaltyTransaction.created. They are records of things that already happened somewhere else, and a migration of a hundred thousand entries should not arrive at your webhook endpoint as a hundred thousand new ones. Everything the call wrote is in its response.

Where to go next