How White Label Loyalty thinks about API design

Idempotency, versioning, and the reasoning behind the choices we made.

Most of the APIs we build at White Label Loyalty end up somewhere we never see. Inside an online store, behind a mobile app, wired into a point of sale that gets touched once a year. Nobody rewrites that integration for fun. Once it works, it stays.

 

That single fact shapes almost every decision we make. When an API sits in production for years, the qualities that matter are not the clever ones, they are the boring ones. Is it predictable? Is it hard to misuse? Can you build on it today and trust it to behave the same way next year? This is how we think about getting those things right, and why we chose what we chose. Each section below takes one practice we hold to, says why it is worth following, and shows what we do about it.

 

What this article covers:

 

  • We model what happened, not what to do about it. You report an event, the platform decides what it's worth. Change a loyalty rule and the integration never moves.
  • Retrying is always safe. Send the same event twice and you get a 409, never a double reward.
  • We only add, never break. v1 is a contract. New capability goes on top of it, so an integration written today still works next year.
  • Every endpoint answers the same way. One response shape, one error model, one way to page a list. Learn one endpoint and you've learned all of them.
  • Bad data is rejected at the door. Every event type has its own schema, validated on arrival, so problems surface as errors instead of missing points.
  • Every request proves who it is. An API key identifies the tenant, a token identifies the actor, and access is scoped to the role.

Start with events, not actions

There is an old principle in API design: capture what happened, not what to do about it. Describe the facts and let the system decide how to act on them, instead of making the caller spell out every step.

 

It is worth following because commands quietly couple the caller to your business logic. Most APIs are built around them. You call "add 50 points", or "set this member to gold", or "send this reward". That feels natural, but the client now has to know that a purchase is worth 50 points, that spending 500 makes someone gold, that this reward follows that action. The day the customer changes any of those rules, every client that baked them in has to change too, and now you are coordinating deployments and chasing version drift across integrations you do not control.

 

So our API works the other way. You rarely tell it what to do, you tell it what happened. You report an event, a plain statement of something that happened in the real world:

 

POST /v1/events
X-Api-Key: <your tenant key>
Authorization: Bearer <token>

{
  "type": "PURCHASE",
  "occurredAt": "2025-03-14T09:31:00.000Z",
  "payload": {
    "total": 42.50,
    "currency": "GBP",
    "storeId": "camden-road"
  }
}

 

A purchase, a scan, a sign-up, a referral. The event says what occurred. It does not say what it is worth. That decision is the customer's, configured in our engine as a set of reactions the platform runs when an event arrives. One event might earn points, nudge a member toward the next tier, unlock a reward, and feed a marketing campaign, all at once, and none of that logic sits in the client.

 

The payoff is that the rules stay the customer's to change as their program evolves. A customer can double points for a bank holiday weekend, add a new tier, or launch a promotion, and the integration never moves. The events keep flowing exactly as before. Only the configured reactions change. For a customer, an integration you set up once and never revisit is worth far more than one with a bespoke endpoint for every feature, because every one of those endpoints is a future migration waiting to happen.

 

One POST /v1/events, and the platform validates it, stores it, and reacts. Points, tiers, rewards, and campaigns all follow from the event, after the response has already returned.

One POST /v1/events, and the platform validates it, stores it, and reacts. Points, tiers, rewards, and campaigns all follow from the event, after the response has already returned.

 

There is a second thing hiding in that picture. The response comes back as soon as the event is accepted and stored. The reactions run behind it. That split is deliberate. The write path stays fast and simple, so reporting an event is cheap and reliable even under load, and the heavier work of applying rules, granting rewards, and firing campaigns happens independently, where it can be retried on its own without the client waiting or caring.

 

Because reactions are asynchronous, we make the lifecycle of an event visible in the data. Every event carries four timestamps:

Every event carries four timestamps that trace its journey from the real world to the finished reactions.Every event carries four timestamps that trace its journey from the real world to the finished reactions.

Those four stamps sound like a small detail, but they are the difference between "the member says they never got their points" being a guess and being answerable. occurredAt is when the thing actually happened, and the client sets it, so a scan taken offline and synced an hour later still records the real moment. reportedAt is when it reached us, registeredAt is when we stored it, and reactedAt is when the downstream reactions finished. With all four you can see exactly where an event is in its journey and how long each step took, instead of guessing.

 

One more design choice sits at the front door. Not every event comes from the same kind of caller, so events come in two flavours. A public event is self-reported by a signed-in user, and the platform infers the subject from their token. A private event is reported by a trusted server, a point of sale or a partner system, using a secret tied to that event type, and it names its subject explicitly. Same endpoint, same shape, but the trust model matches who is really sending the data. That thread runs straight into how we think about auth, which we come back to at the end.

Retrying should always be safe

This section is about idempotency, the idea that an operation should be safe to repeat, so that doing it twice leaves the system in the same state as doing it once.

 

Anything that talks over a network has to assume messages arrive more than once. A client times out and retries. A mobile app is backgrounded mid-request and tries again on resume. A queue redelivers. In a loyalty system that assumption has teeth. A duplicated request means points a member did not earn, or a reward that went out twice. If retries are not safe, every client ends up building its own bookkeeping to avoid them, and some will get it wrong.

 

So we make safe retries a property of the API itself, not something every client has to remember to get right.

An event is a statement of fact, and the same fact twice is still one fact. If an identical event arrives again, same type, same subject, same payload, we recognise it rather than process it a second time, and we tell you plainly:

 

HTTP 409
{
  "status": "fail",
  "message": "Event already exists"
}

 

That means a nervous client can resend without doing any damage. The worst outcome of a retry is a 409, never a double reward. The client does not have to reason about whether its first attempt landed before the connection dropped. It can just try again.

 

We would rather the safe outcome be the default than depend on every client being careful. A design where the naive thing is also the correct thing is one you can hand to a hurried developer and still trust.

Add, don't break

Good API versioning has one job: let the API evolve without breaking the people already using it. Version it explicitly, so everyone knows which contract they are on, and make your changes additive.

 

It matters because the fastest way to lose a developer's trust is to break something that worked yesterday. A breaking change is a forced migration that lands on someone else's roadmap at a time they did not choose. For an integration meant to run for years, that cost is real, so we would rather move slowly than impose it.

 

So here is what we do. The version lives in the URL, and it is stable:

 

https://api.rewards.wlloyalty.net/v1/...

 

We treat v1 as a contract. New capability is additive: a new optional field, a new endpoint, a new value in a list. We steer clear of the changes that break people, silently dropping a field, renaming one, or quietly changing what an existing one means. When something genuinely does need replacing, we add the replacement alongside the old path and deprecate the old one with notice, rather than pulling it out from under a running integration. So an integration written against v1 keeps behaving the way it did, because new work goes on top of the contract rather than through it.

 

This only works if both sides play their part, so it is worth being explicit about the contract it implies. We promise not to take things away. In return, a well-behaved client ignores fields it does not recognise instead of choking on them. That tolerance is what lets us add without a negotiation every time. Be strict in what you send and forgiving in what you accept, and a long-lived API can keep growing without a single breaking change.

 

It helps that a great deal of what looks like new features never touches the API surface at all. Because the rules live in reactions we configure rather than in endpoints you call, adding a tier, changing an earning rule, or running a campaign is configuration, not a contract change. The API stays still while the product moves, and that is a large part of why we have not needed to churn versions.

 

Now the honest trade-off, because there is one. Some companies version aggressively and cut new major versions often as they learn. That suits a product whose customers are developers who read a changelog every week and enjoy staying on the edge. Ours are businesses who integrated once and want it to keep working while they get on with running a shop. For them, stability beats novelty every time. So we deliberately push the cost of change onto ourselves, in the discipline it takes to only ever add, rather than onto our customers, in the migrations they would otherwise have to run.

One shape for everything

Consistency is the least glamorous practice on this list and one of the highest-leverage. An API surface should feel like one system: the same response envelope, the same error model, the same way of paging a list, from one end of it to the other.

 

Every inconsistency is one more thing a developer has to learn, remember, and special-case. Ten endpoints that each answer a little differently are ten times the surface to hold in your head. Consistency is what turns integrating the tenth endpoint into almost no work at all.

 

What we do is build our services on the same internal foundation, so the shape is shared rather than reinvented team by team. Start with the envelope. A successful response carries your data under a data key, next to a status:

 

{
  "status": "success",
  "data": { ... }
}

 

Every failure looks the same too, and it tells you which side the problem is on. A fail is something about your request, a validation error or a missing resource. An error is something that went wrong on ours. And the HTTP status code always means what you would expect: 400 for a bad payload, 401 for a missing or invalid token, 403 for a bad API key, 404 for something that is not there, 409 when we detect a duplicate event.

 

When we reject something, we try to tell you exactly what and why, in a shape you can act on programmatically rather than a sentence you have to parse:

 

{
  "status": "fail",
  "message": "Invalid body, check 'errors' property for more info.",
  "data": {
    "name": "ValidationError",
    "errors": [
      {
        "property": "type",
        "constraints": {
          "matches": "type must match /^[A-Z]+(_[A-Z]+)*$/"
        }
      }
    ]
  }
}

 

That structure matters more than it looks. A machine-readable name lets a client branch on the kind of error. The errors list points at the exact field and the exact rule it broke, so a front end can put the message next to the right input instead of showing a generic failure. Good error design is part of the API, not an afterthought bolted on when something goes wrong.

 

The same consistency runs through the parts people forget to make consistent. Lists paginate the same way everywhere, with skip and limit, and the total count comes back in a Content-Range header so you can build a pager without a second call. Sorting and filtering follow one convention across every collection. None of this is exciting on its own. The value is cumulative: once you have integrated one endpoint, the tenth costs almost nothing, because it answers you in a language you already speak.

Validate at the door

The rule is to validate input at the boundary and reject bad data immediately, with a message that says what is wrong. Fail fast, at the edge, while the caller can still do something about it.

 

It matters because an event platform is only as trustworthy as the data going into it, and bad data has a nasty habit of failing quietly. A malformed payload that slips past the front door does not blow up where you would notice. It surfaces three reactions later as points that never landed or a reward that never fired, and by then it is a support ticket instead of an error message.

 

So we check at the door. There are really two layers to it. The request itself is validated for shape, the right fields of the right types in the right place, before it is allowed any further. That catches the obvious mistakes and answers them with the structured error you just saw.

 

The more interesting layer is specific to events. Every event type carries its own JSON Schema, defined by our customers, and every event of that type is validated against it the moment it arrives:

 

{
  "type": "object",
  "required": ["total", "currency"],
  "properties": {
    "total":    { "type": "number" },
    "currency": { "type": "string" }
  }
}

 

Send a PURCHASE without a currency and you find out immediately, at the boundary, with a message that names the problem:

 

HTTP 400
{
  "status": "fail",
  "message": "Malformed payload: data must have required property 'currency'",
  "data": {
    "name": "BadRequestError"
  }
}

 

The point of putting the schema on the event type is ownership. The person modelling the event decides what a valid one looks like, and that definition lives next to the meaning of the event rather than buried in generic handling code somewhere downstream. It also evolves the same way the rest of the API does, by adding optional fields rather than tightening old ones, so a schema can grow without breaking the events already flowing against it. Bad data does not get to become a bad reward.

Two keys on every request

The governing idea here is least privilege: every request should prove who it is and be granted exactly the access its role needs, no more. On a white-label platform there is a second half to it, hard isolation between tenants, because many customers share one system and none may ever see another's data.

 

It matters for the obvious reason and a quieter one. The obvious reason is security. The quieter one is that clear, predictable auth is part of the developer experience, an integrator should never have to guess what a given call is allowed to do.

 

Here is what we do. Every request carries two things that answer two different questions. An X-Api-Key answers which tenant this is. It identifies the customer's account and scopes everything that follows it, so every call is tenant-isolated by default. A bearer token answers who is acting. It identifies the user or the admin behind the request, and what they are allowed to do.

 

Put together, they give three clean levels of access. Anonymous, with just the API key, for the handful of things that need no user context. User, with the key plus an end user's token, for someone acting on their own account. An admin, with the key plus an administrative token, for management operations, where individual permissions are scoped by name so a token can be allowed to read one kind of resource without being handed the keys to everything.

 

On top of that baseline there are a couple of specialised paths for the cases that need them. Trusted server-to-server systems can report events using a secret tied to the event type, as mentioned earlier, without carrying a user token at all. And an admin can act on behalf of a user when support genuinely requires it, through an explicit impersonation header, so those actions are deliberate and attributable rather than a silent side effect. The theme is least privilege. Every request says who it is, and gets exactly the access that role should have, no more.

Boring on purpose

Step back and the individual choices all lean the same way. Model what happened instead of what to do, so the rules can change without the integration moving. Make retries safe, so a dropped connection is never a double charge. Add without breaking, so today's integration still works next year. Return one shape everywhere, so learning one endpoint teaches you all of them. Validate at the door, so bad data fails loudly and early. Scope every request, so a shared platform stays private.

 

None of these is a headline feature. Put together, they add up to an API that does not surprise you, and for something that lives inside a customer's checkout for years, not being surprising is the whole job. That is what we mean when we talk about API design. Not the clever parts. The parts you can rely on.

 

Where to go next

 

Recommended Posts

If you enjoyed this article, check out these relevant posts below.

Share this Article

Tharindu Perera

Tharindu Perera

Software Engineer

Post Tags

Data
API
Technical Content