Back to BlogIndustry Guides

Retail Software Development: Omnichannel Stock, POS Integration and Cost

Rupak Amin

Founder & Lead Engineer, RAITHub

15 min read

Retail software development builds the systems between your tills, web store and stockroom: one stock ledger every channel writes to, a point-of-sale (POS) integration through your payment provider, and the back office your staff use. Cost follows how many channels and integrations you connect. With e-commerce at 17.1% of US retail sales, most shops need store and web to agree.

This guide is for retail owners, operations leads and technical founders who already sell in more than one place and are tired of stock numbers that disagree. It covers the parts of a retail build, how to model stock across locations, a POS-to-web sync that survives retries, how card-present payments fit in, and what drives the cost. The US figure above is the Census Bureau's estimate for the second quarter of 2026, released on 18 August 2026: $340.2 billion of e-commerce sales, seasonally adjusted.

What does retail software development actually cover?

Usually not a whole retail suite. Most retailers keep a POS and accounting tool they are happy with, and need custom work in the gaps between systems. The common pieces:

PieceWhat it doesOften bought or built?
Stock ledgerOne record of stock per variant and location that every channel writes toBuilt, when channels disagree or batches matter
Web storeCatalogue, cart, checkout, customer accountsBought (hosted platform) or built, depending on rules; see Shopify vs custom ecommerce
POSTill software and card reader in the shopUsually bought; a custom web POS only when the till must run your own rules
Channel syncMoves sales and stock between POS, web store and marketplacesBuilt, because the rules are yours
Back officeReceiving, transfers, counts, returns, staff roles, reportsBuilt when the workflow is specific; bought when it is standard
Wholesale portalTrade accounts, tier pricing, creditBuilt; see B2B ecommerce platform development

Why do store and web stock numbers drift apart?

Because each system keeps its own copy of the number and they copy absolute counts to each other. Three failure patterns cause most of the drift:

  • Last write wins. The POS says "stock is 4", the web store says "stock is 5", and whichever sync runs last overwrites a real sale.
  • Duplicates. Webhooks and sync jobs retry. Shopify's documentation says "your app might receive the same webhook more than once, for example after a network timeout or a retry" (Shopify: ignore duplicate webhooks). A sale applied twice removes stock you still have.
  • Out of order. A return processed before the sale it refunds, or a count posted before the morning's sales arrive.

The fix is structural: one system owns stock, every channel sends it movements (a sale of 1, a receipt of 24) rather than totals, and every movement carries a unique reference so it is applied exactly once. Deltas also make order matter less: a sale and a return add up to the same total whichever arrives first.

How should you model stock across stores and a web shop?

Per variant, per location, with the stock that is physically there separated from the stock already promised. What the web shop may sell is then a calculation, not a stored number:

FieldMeaningChanged by
on_handUnits physically at the locationTill sales, receipts, transfers, counts, returns
reservedUnits promised to web orders not yet pickedWeb checkout, cancellation, picking
safetyUnits kept back from the web, for walk-in customers or miscountsStaff setting per location
available to the webon_hand minus reserved minus safetyCalculated, never stored

A web order then reserves stock at the first location, in your fulfilment order, that can fill the line:

CREATE TABLE location_stock (
  variant_id  bigint  NOT NULL,
  location_id bigint  NOT NULL,
  on_hand     integer NOT NULL,              -- no floor: see the POS section below
  reserved    integer NOT NULL DEFAULT 0 CHECK (reserved >= 0),
  safety      integer NOT NULL DEFAULT 0 CHECK (safety >= 0),
  PRIMARY KEY (variant_id, location_id)
);

-- $1 variant, $2 quantity, $3 locations allowed to fulfil web orders, in priority order
UPDATE location_stock
SET reserved = reserved + $2
WHERE (variant_id, location_id) = (
    SELECT variant_id, location_id
    FROM location_stock
    WHERE variant_id = $1
      AND location_id = ANY ($3::bigint[])
      AND on_hand - reserved - safety >= $2
    ORDER BY array_position($3::bigint[], location_id)
    LIMIT 1
    FOR UPDATE
  )
  AND on_hand - reserved - safety >= $2
RETURNING location_id;

Run it inside the order transaction. If it returns a location, the units are held there. If it returns nothing, no single location can fill the line, and your rules decide whether to split the order, back-order it or refuse it. The condition is repeated on the outer UPDATE so the check and the write stay one step. The single-location version of this pattern, including lock ordering and a concurrency test, is in how to stop overselling with stock reservation.

How do you sync POS sales to the web store without double-counting?

Treat each POS sale as an event: verify it, record that you have seen it, apply each line as a movement with a unique reference, and push the new availability to the web after the commit. This sketch is a Next.js route handler with node-pg, written for this post as engineering guidance. Signature checks differ per provider, so that part is left as a function to fill in.

import { Pool } from 'pg'

const pool = new Pool()

type PosSaleLine = { lineId: string; variantId: number; locationId: number; qty: number }
type PosSaleEvent = { eventId: string; saleId: string; lines: PosSaleLine[] }

// Provider-specific: each POS signs its webhooks differently.
declare function verifySignature(req: Request, rawBody: string): boolean
// Queues a job that recalculates web availability and pushes it to each channel.
declare function enqueueChannelPush(variantIds: number[]): Promise<void>

export async function POST(req: Request) {
  const raw = await req.text()
  if (!verifySignature(req, raw)) return new Response('invalid signature', { status: 401 })
  const event = JSON.parse(raw) as PosSaleEvent

  const client = await pool.connect()
  try {
    await client.query('BEGIN')

    // 1. Has this delivery been processed already?
    const first = await client.query(
      'INSERT INTO processed_events (provider, event_id) VALUES ($1, $2) ' +
        'ON CONFLICT DO NOTHING RETURNING event_id',
      ['pos', event.eventId],
    )
    if (first.rowCount === 0) {
      await client.query('ROLLBACK')
      return new Response('duplicate', { status: 200 })
    }

    // 2. Apply each line once, as a delta. Sorted so concurrent events lock rows in the same order.
    const lines = [...event.lines].sort(
      (a, b) => a.variantId - b.variantId || a.locationId - b.locationId,
    )
    for (const line of lines) {
      const moved = await client.query(
        'INSERT INTO stock_movements (source, source_ref, variant_id, location_id, delta) ' +
          'VALUES ($1, $2, $3, $4, $5) ON CONFLICT (source, source_ref) DO NOTHING RETURNING id',
        ['pos', event.saleId + ':' + line.lineId, line.variantId, line.locationId, -line.qty],
      )
      if (moved.rowCount === 0) continue // this sale line arrived earlier in another event

      const stock = await client.query(
        'UPDATE location_stock SET on_hand = on_hand - $3 ' +
          'WHERE variant_id = $1 AND location_id = $2 RETURNING on_hand',
        [line.variantId, line.locationId, line.qty],
      )
      // The customer has already left with the item: never reject a till sale. Flag it.
      if (stock.rowCount === 0 || stock.rows[0].on_hand < 0) {
        await client.query(
          'INSERT INTO stock_exceptions (movement_id, reason) VALUES ($1, $2)',
          [moved.rows[0].id, stock.rowCount === 0 ? 'unknown_item' : 'negative_on_hand'],
        )
      }
    }

    await client.query('COMMIT')
  } catch (err) {
    await client.query('ROLLBACK')
    throw err // a 500 makes the provider retry, and the unique keys make the retry safe
  } finally {
    client.release()
  }

  // 3. After the commit, never inside it: a slow channel API must not hold stock rows locked.
  await enqueueChannelPush(event.lines.map((l) => l.variantId))
  return new Response('ok', { status: 200 })
}

Why two unique keys? processed_events catches the same delivery arriving twice. The (source, source_ref) key on stock_movements catches the same sale line arriving inside two different events, for example a "sale created" and a "sale updated" notification. Both rely on PostgreSQL's ON CONFLICT DO NOTHING, which "simply avoids inserting a row as its alternative action", combined with RETURNING, where "only rows that were successfully inserted or updated will be returned" (PostgreSQL: INSERT).

The biggest difference from web checkout is the rule in the middle. A web order can be refused when stock runs out. A till sale cannot: the item has already left the shop. So on_hand deliberately has no floor, and a negative number becomes an exception for staff to investigate, usually a miscount or a sale rung up against the wrong variant. Testing this kind of handler, including duplicate and out-of-order deliveries, is covered in testing payments and webhooks.

How do you push stock back to Shopify or Square safely?

Use the vendor's compare-and-set and idempotency features, so a retried push cannot overwrite a newer number:

  • Shopify. The inventorySetQuantities mutation "will only update the quantity if the persisted quantity matches the compareQuantity value" unless you opt out, and Shopify's documentation says that as of API version 2026-04 "the idempotency key is required and must be provided using the @idempotent directive" (Shopify: inventorySetQuantities). Its warning is worth repeating: opting out of the check "can lead to inaccurate inventory quantities if multiple requests are made concurrently".
  • Square. BatchChangeInventory takes an idempotency_key, "a client-supplied, universally unique identifier (UUID) for the request", and up to 100 changes per call (Square: BatchChangeInventory).

Derive the key from the movement or the push job, not a fresh random value on every retry, or the retry is not recognised as one.

How do card payments at the till fit into a custom retail system?

Through your payment provider's in-person product, not by handling cards yourself. RAITHub has not shipped a POS hardware or Stripe Terminal integration, so this section is engineering guidance drawn from Stripe's documentation, not a case study.

Stripe Terminal "supports an API-based integration in addition to SDKs for Android, iOS, JavaScript, and React Native" (Stripe Terminal documentation). For a browser-based POS, that means the JavaScript SDK or a server-driven integration with a smart reader on the shop network. Three points shape the stock design:

Fact from Stripe's documentationWhat it means for stock
Offline, a PaymentIntent has a null id, and Stripe recommends "adding a custom identifier to the PaymentIntent's metadata"Use your own sale ID as that identifier and as the stock movement's source_ref, so stock moves once whether payment is forwarded now or later
Offline, "authorization is only attempted after connectivity is restored", and "you, as the user, assume all decline and tamper-related risks"Decrement stock at the sale, but keep payment status separate so a later decline flags the sale rather than rewriting stock
Offline transactions have a "Stripe-enforced offline maximum of 10,000 USD or equivalent in your operating currency"Set your own lower limit per sale and per stored total, as Stripe's guide suggests

Sources: Stripe: collect card payments while offline. For online calls, Stripe's idempotency keys make retries safe: Stripe saves "the resulting status code and body of the first request made for any given idempotency key", and keys may be pruned after they are at least 24 hours old (Stripe: idempotent requests). Other providers work differently, and some markets rely on local wallets and cash, so the ledger should not assume one payment method.

What drives the cost of retail software development?

Integrations and rules, far more than screens. RAITHub does not publish rates; each project gets a free 15-minute technical audit and then a fixed written quote. The effort ratings below are engineering judgement, not a price list:

Cost driverLower effortHigher effort
ChannelsOne web store and one POSSeveral stores, a web store and marketplaces
POS approachKeep the vendor's POS; consume its webhooks and APIA custom web POS with card readers
Stock rulesOne count per variant per locationBatches, expiry and FEFO, minimum shelf life
PricingOne retail priceWholesale tiers, credit, per-customer prices
Data migrationClean product exportYears of spreadsheets with duplicate SKUs
Staff rolesOwner and staffPickers, counters, approvers with separation of duties

Set that against what you pay now. Shopify POS Pro is US$89, £69 or A$129 a month per location on top of a Shopify plan (US, UK, Australia); Square for Retail Plus is £49 a month per location in the UK (Square UK pricing); Lightspeed Retail runs from $89 to $289 a month (Lightspeed pricing). Five locations on POS Pro alone is $445 a month. A custom build rarely replaces those tills; it replaces the spreadsheets and manual syncs around them. The cost estimator gives a first range, and pricing explains how quotes work.

What has RAITHub built for retail and commerce?

Two platforms carry the stock and money patterns in this post.

  • Sundor Skin is a B2B skincare wholesale platform RAITHub designed and built: 146 PostgreSQL tables with row-level security, 530+ automated tests, 88 permission codes, 12 staff roles, tier pricing, credit limits and FEFO batch fulfilment. Pricing, credit and stock allocation run inside one database transaction. See the Sundor Skin case study.
  • TheSkinProof is the founder's own venture, a verified-skincare marketplace built and run by RAITHub, not a client project. It has 217 API endpoints, 750+ automated tests and 5 role-based portals, including a warehouse portal, and decrements per-variant stock inside the order transaction across bKash, Nagad, SSLCommerz and cash on delivery. See the TheSkinProof case study.

Neither includes a card-reader POS. Both are web platforms where stock and money correctness is tested, which is the part of retail software that fails quietly.

Why RAITHub for this

  • Stock ledgers in production. Per-variant reservation on TheSkinProof and batch-level FEFO allocation on Sundor Skin, both inside database transactions.
  • Retries treated as normal. Webhooks, sync jobs and payment callbacks are built to run twice safely, with tests for duplicates and out-of-order delivery.
  • Honest about the POS. RAITHub builds the web side: ledgers, channel sync, back offices, web POS screens and PWAs. Card-reader integration would be new ground and is scoped as such.
  • Hours that reach you. RAITHub works from Dhaka (UTC+6). US and Canada clients get a daily 2-hour window, Dhaka 19:00 to 21:00, which is 08:00 to 10:00 in New York and Toronto. Central Europe gets 4 hours of overlap in winter and 5 in summer, the UK 3 and 4, Dubai 7, Singapore 7, and Sydney 5 (4 during daylight saving). The working week is agreed per client; the rest runs on written daily handoffs.
  • Fixed scope, your code. A free 15-minute technical audit, then a written fixed quote. You own the code, and an NDA is standard. See the API and backend development service.

When you don't need us

  • One store, one web shop from the same vendor. A POS and online store sold together already share stock. Configure it.
  • A connector app already does the sync. If an off-the-shelf connector keeps your channels in step without a spreadsheet, keep it.
  • You need a native iOS or Android POS app. RAITHub builds web applications and PWAs, not native mobile apps.
  • You need card terminals certified or installed on site. That is your payment provider's and hardware reseller's job. RAITHub has no local office to install hardware.
  • You want developers placed in your team by the hour. RAITHub offers fixed-scope builds and dedicated teams, not staff augmentation.

If a sync is already double-counting, the fix service handles a single issue. For a larger rebuild of the web side, see when to move from Shopify to headless, custom ecommerce and marketplace development and the ecommerce industry page. Then book the free 15-minute technical audit with a list of your channels, your POS, and one day's stock numbers that disagree.

Last reviewed: 30 September 2026. Vendor prices, documentation and Census figures checked on 30 September 2026.

Frequently asked questions

What is omnichannel inventory management?

One stock record that every sales channel, including shops, a web store and marketplaces, reads from and writes to. Each channel sends sales and receipts as movements with a unique reference, and what the web may sell is calculated as on hand minus reserved minus a safety buffer.

How do I sync inventory between my POS and my online store?

Make one system own stock, have the POS send each sale as an event, record each event and sale line with a unique key so retries are ignored, and push the new availability to the web store after the database commit, using the platform's compare-and-set and idempotency features.

Should a POS sale be rejected if stock shows zero?

No. The customer has already left with the item. Record the sale, let the location go negative, and raise an exception for staff to investigate. Only web orders, which have not shipped yet, should be refused when stock runs out.

Can a custom web app take card payments in a shop?

Yes, through a provider's in-person product. Stripe Terminal, for example, offers a JavaScript SDK and a server-driven integration alongside native SDKs. RAITHub has not shipped a Terminal integration, so treat that as engineering guidance rather than proof.

How much does retail software development cost?

It depends on channels, POS approach, stock rules, pricing and data migration. RAITHub does not publish rates: a free 15-minute technical audit is followed by a fixed written quote. Compare it with your current per-location POS fees and the staff time spent reconciling stock.

Does RAITHub build retail POS systems?

RAITHub builds the web side of retail: stock ledgers, channel sync, back offices, wholesale portals and browser-based screens. Its proof is Sundor Skin and TheSkinProof, the founder's own venture. It does not build native mobile apps and has not shipped a card-reader integration.

retail software developmentomnichannel inventorypos integrationstripe terminalinventory syncidempotency

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.