Home Platform Integrations Customers Pricing Blog
Sign in Request Access
Engineering 9 min read

Omnichannel Point Accrual: Design Patterns That Actually Work

How do you ensure a customer earns the right points whether they buy in-store at checkout or via your mobile app at 11pm? A breakdown of the event schema patterns loyalty engineers use.

Point accrual sounds like a simple problem — a customer spends money, they earn points proportional to the spend. But the moment you try to implement that rule consistently across an in-store POS, an e-commerce checkout, and a mobile app with its own checkout flow, you're dealing with three different event producers, three different transaction schemas, and three different timing models. Getting accrual right in all three contexts simultaneously is where loyalty engineering earns its complexity.

This post covers the patterns that hold up under production conditions — not the patterns that look clean in a design doc but fall apart at the first edge case.

The Core: Event-Driven Accrual

The most durable accrual architecture treats every loyalty-relevant action as an event with a canonical schema. The event schema is your contract. Every touchpoint — POS, web checkout, mobile app — must produce events that conform to this schema before they reach the accrual engine. The engine should be opinionated about what it accepts and reject malformed events explicitly rather than silently.

A minimal accrual event schema for a retail loyalty program looks roughly like this:

{
  "event_id": "evnt_9f4a2b1c",
  "event_type": "purchase_completed",
  "member_id": "mbr_00482917",
  "channel": "in_store",
  "store_id": "store_atl_012",
  "transaction_id": "txn_2025011423841",
  "transaction_ts": "2025-01-14T14:23:41Z",
  "line_items": [
    {
      "sku": "SKU-40921",
      "category": "outerwear",
      "unit_price_cents": 8999,
      "quantity": 1,
      "eligible_for_points": true
    }
  ],
  "subtotal_cents": 8999,
  "eligible_subtotal_cents": 8999,
  "currency": "USD",
  "idempotency_key": "txn_2025011423841_pos"
}

Several things in that schema matter more than they look:

The eligible_subtotal_cents field

Not every dollar in a transaction is points-eligible. If your program excludes alcohol, tobacco, gift cards, or certain promotional items from accrual, the POS must compute and pass the eligible subtotal explicitly — the accrual engine should not infer eligibility from the line items unless you want to replicate that logic in two places. Passing it as a separate field is safer and more auditable.

The idempotency_key field

This is the single most important field for preventing double-accrual. Network retries happen. Webhooks fire twice. A customer's POS payment goes through but the network drops the loyalty event confirmation, so the POS retries. Without an idempotency key, the member earns points twice for one transaction. With it, the accrual engine can detect and reject the duplicate. The key should be constructed from a stable transaction identifier — not a timestamp, which can duplicate under high concurrency.

The channel field

Channels matter for two reasons: bonus multiplier rules often apply per channel ("earn 2x points on all online orders in February"), and fraud detection patterns rely on channel distribution analysis. A member who suddenly shifts from 90% in-store to 100% online during a mobile-only bonus period isn't necessarily suspicious — but you want the channel recorded so you can distinguish that from genuine multi-account farming.

Handling the POS Event Timing Problem

In-store POS events have a specific timing challenge: the transaction completes at the register, but the loyalty event may not fire until the POS syncs with its back-end host. In some configurations — particularly with older NCR Aloha deployments running in a store-level LAN with periodic cloud sync — this lag can be 10–30 minutes. In intermittent connectivity scenarios (a store in a rural location with a spotty internet connection), it can be hours.

Your accrual engine needs to handle late-arriving events gracefully. This means:

  • Events must be accepted up to N hours after the transaction timestamp, not just N hours after the event was submitted. Use the transaction_ts field, not the event arrival time, as the authoritative timestamp for accrual.
  • Member balance reads during the lag window should optionally surface a "pending accrual" indicator rather than showing a balance that doesn't reflect a recent known transaction.
  • Redemption eligibility checks during the lag window require a decision: do you allow redemption against a possibly-stale balance, or do you hold redemption? Most programs opt for the former with a light "your balance may not reflect recent activity" caveat, because blocking redemption on a valid balance creates more friction than it prevents.

Bonus Multipliers and Campaign Overrides

The basic earn rate — say, 1 point per dollar spent — is rarely sufficient for a mature retail loyalty program. Programs layer bonus multipliers on top of the base rate: category bonuses ("3x on outdoor gear during Q1"), tier bonuses ("Gold members earn 1.5x"), and promotional campaign overrides ("5x on all purchases this weekend for enrolled members").

The pattern that scales cleanly is a prioritized rule stack: given a transaction event, evaluate all active rules that match its attributes (channel, category, tier, member segment, promotion enrollment) and apply them in priority order, with a defined conflict resolution strategy. Most programs use "take the highest multiplier" rather than "stack all multipliers additively," because stacked multipliers on category bonuses during promotional periods can generate liability that wasn't budgeted.

We're not saying stacked multipliers are always wrong — there are programs where additive stacking is intentional and profitable. We're saying that if stacking is unintentional, the liability compounds faster than most loyalty managers realize before the first reconciliation.

Fractional Points and Rounding Strategy

A 1 point per dollar rate is clean. A 1.25 points per dollar rate with a $13.47 transaction produces 16.8375 points. What do you do with the fraction?

Three common strategies, each with tradeoffs:

  • Floor (always round down): Simplest accounting, slightly favors the program over members at low transaction values. Standard for programs where points are high-value (e.g., 100 points = $1 discount).
  • Round to nearest: Member-friendly, produces a small but real liability variance at scale that needs to be tracked. Appropriate for programs where fractional points round to a negligible value.
  • Accumulate fractional points: The accrual engine stores fractional points in the member's ledger and they contribute to whole-point balance over time. Requires your point store to use a decimal type, not integer. Prevents the "always floor" loyalty tax on small purchases.

The choice should be documented in your program rules and reflected precisely in your accrual engine config. Inconsistency between what your program documentation says and how the engine rounds is the kind of thing that surfaces in a customer dispute and looks bad.

Void and Refund Handling

A transaction is voided at the POS. A purchase is refunded 10 days later on the e-commerce platform. Points awarded for those transactions need to be reversed. This is the most common source of loyalty balance errors in programs that get the happy path right but skip the reversal path.

Reversal events should mirror the original accrual event structure, with an additional reversal_of field linking back to the original event ID. The accrual engine should:

  • Verify the reversal references a valid original event in the same member's ledger.
  • Check whether the member has already redeemed the points being reversed. If they have, the program has a liability — handle this per your program rules (most programs absorb small amounts, flag large reversals for manual review).
  • Reject reversals for events outside a configurable lookback window (typically 90–180 days) to prevent retroactive manipulation.

Elmdale Market, a 34-store grocery chain, discovered during an early integration audit that their e-commerce platform was firing refund events against their legacy loyalty system but the POS-side loyalty module had no refund event handler — it simply ignored reversal notifications. Over 18 months, this silently inflated member balances by a small but non-trivial amount. The detection came during a balance sheet reconciliation, not from any alert. Proper reversal handling, with explicit rejection logging, would have surfaced the gap within the first billing cycle.

Multi-Channel Deduplication

Buy-online-pickup-in-store (BOPIS) orders create a deduplication challenge: the checkout event fires from the e-commerce platform when the order is placed, and some POS systems also fire a transaction event when the customer picks up and the order is closed at the register. Unless your accrual engine knows these are the same transaction, the member earns points twice.

The reliable solution is an order-level identifier that persists across both events — an order ID that the e-commerce system generates and the POS system receives as part of the order handoff. The accrual engine treats the first event as definitive and rejects subsequent events with the same order ID as duplicates. The idempotency_key in our schema above can carry this order ID in BOPIS scenarios.

Getting this right requires coordination between your e-commerce platform and your POS integration layer, which is exactly why it's a step that often gets skipped during initial loyalty implementations and then becomes a recurring support issue.

Testing Accrual Logic in Non-Production

A loyalty accrual engine is a financial system. Testing it deserves the same rigor as testing payment processing. Minimum coverage for a production-grade accrual engine:

  • Happy path: standard purchase across each channel
  • Idempotency: same event submitted twice — second must be rejected, balance unchanged
  • Late arrival: event with a transaction timestamp 6 hours in the past
  • Void: purchase event followed by a void event — net points should be zero
  • Fractional rounding: verify rounding behavior matches documented strategy
  • Multiplier conflict: overlapping bonus rules — verify conflict resolution applies correctly
  • BOPIS deduplication: matching order IDs from two channels — second must be rejected
  • Ineligible SKU: transaction containing both eligible and ineligible line items — only eligible subtotal earns

Each of these is a test case that, if it fails in production, produces a member-visible error or a silent balance discrepancy. Silent discrepancies are worse — they go undetected longer and accumulate into larger problems.