A loyalty product looks small from the outside. A customer scans something, a number goes up, and eventually they get a free coffee. Almost none of the engineering effort goes into that. It goes into making the number correct, keeping it correct when two things happen at once, and stopping anyone from making it go up without earning it. This is drawn from building a rewards platform on Node, Express and MongoDB; the shape applies to any stack.
Points are a financial record even when they are not legally money, and customers notice a wrong balance immediately. So the first architectural decision is that balances are derived, not authored. Keep an append-only transaction collection where every row records the paying party, the receiving party, source and destination wallet, a typed reason, an amount and a status. The account may cache a balance for fast reads, but that cache is only ever moved by an atomic increment, never assigned.
The mistake to avoid is debiting one wallet, crediting another, and writing the transaction record as three separate calls. A crash between the second and third leaves value that exists in balances but appears nowhere in history, and you find out weeks later when a partner queries their statement. Wrap the movement in a multi-document transaction so it commits or rolls back as a unit. On MongoDB that means running a replica set even in development, since transactions are unavailable on a standalone server.
You will not get every step inside the session, because a call to an external provider cannot join a database transaction. That is fine if it is explicit: document which side compensates, implement the reversal, and test it by throwing inside the transaction on purpose. Status also needs more than success and failure, since real flows include awards pending a partner's confirmation. And a scheduled job should sum the ledger against cached balances, because the cache will drift.
A loyalty platform has more account types than people expect: members, partner businesses, staff working under those businesses, administrators, often a sales layer on top. Value moving between all of them pushes you towards polymorphic references, where a transaction stores both an identifier and the collection it points at. Useful once the ledger spans account types, a waste if you only have two: population becomes dynamic rather than a fixed join, an aggregation lookup targets one collection at a time, and type correctness moves into your own code.
Authorisation is the harder half. Centralise it in one middleware that verifies the token, loads the account, checks the role against an allowed list, and compares a token version field so sessions can be revoked without waiting for expiry. Then hold one rule with no exceptions: the tenant identifier that scopes a query comes from the verified token, never from the request body, path or query string. A dashboard that filters by a client-supplied business ID is one curious user away from being everyone's dashboard.
Printed cards, window stickers and table talkers live for years and you control none of them once they leave. Encode a short opaque code that resolves server-side to an account, rather than the account identifier itself. The indirection lets you revoke a compromised code, re-point a reprinted batch and see scan analytics without reissuing anything physical.
What you must not do is put value in the code: no signed point totals, no bearer reward tokens, nothing a scanner could act on by itself. Anything scannable is copyable, and a photograph of a card duplicates it perfectly. The scan identifies a person; a separate, staff-authenticated endpoint decides what to award and records who authorised it. Rate limit short-code lookups separately from the rest of the API and keep the code space sparse, because short codes are worth guessing in a way database identifiers are not. In print, pin the version and error correction level: higher correction survives scuffing but costs capacity, pushing the payload up a version.
An Apple Wallet pass is a zip archive containing the pass JSON, its assets, a manifest of file hashes and a detached signature over that manifest. Generating one with a library such as passkit-generator is the easy half. The work sits around it:
Decide before you start whether passes update. Keeping one live means embedding a web service URL and authentication token in the pass, running the registration and update endpoints Apple specifies, and sending empty pushes that prompt the device to fetch a new version. Skipping that subsystem is legitimate, but then design the pass so staleness is harmless: show identity and membership, not a balance that silently goes wrong. Google Wallet updates passes as objects through its API, so Android is a second integration rather than a port.
Loyalty products carry behaviour no user triggers: activity aggregation, statement totals, reward expiry, cancelling subscriptions at period end. A database-backed scheduler suits this, because the job store is a database you already run and it coordinates across instances. Agenda is a common choice on MongoDB, pg-boss on Postgres; check how actively each is maintained before committing. Jobs must still be idempotent, because a lock can lapse mid-run and the work will be picked up again. And nothing scheduled belongs in a setInterval inside the web process, because the moment you run two instances every nightly accrual runs twice.
Expiry and accrual are timezone problems wearing a scheduling costume. A reward that expires twelve months after issue lands on different calendar days depending on whether you resolve it in the member's zone, the venue's zone or UTC. Pick one, write the decision down where the next developer will find it, and store the resolved expiry instant on the record at issue time rather than recomputing it from rules that will have changed.
Expect more than one payment integration. Card-present merchants often already run a till system, so you support a hosted checkout provider for platform billing alongside a point-of-sale provider connected per merchant over OAuth. That second one brings obligations: encrypt stored merchant tokens, refresh them before expiry, verify webhook signatures, and treat the webhook as the state of record rather than the response to your own API call. Webhooks arrive at least once and out of order, so handlers must be idempotent.
Provider SDKs accept an idempotency key, but a fresh random one per attempt protects you against your own retry loop, not against a customer tapping pay twice; derive it from the entity being paid for when duplicate submission is the risk, and check the provider's key retention window, which is short. On your own endpoints, a unique index on a client-supplied key, with the duplicate-key error handled by returning the existing record, is the cheapest correct guard.
Fraud here is worth threat-modelling from the inside out. Forged codes get the attention, but the attempts that need no skill are a staff member awarding stamps to friends, someone scanning their own card during a quiet shift, and a partner inflating balances they have not funded. Record the acting user on every award, keep the audit trail queryable, apply velocity limits per member and per device, and give partner owners a view where an unusual pattern is visible without a support ticket.
None of this is exotic. It is ordinary engineering applied carefully where correctness is not negotiable, and the ledger is where getting it wrong compounds quietest.
We design and build AI-powered platforms, web applications, and mobile products. Tell us what you are working on.
Get in touch