Skip to main content

Troubleshooting

Almost every question is "why is this stock number wrong?", and almost every answer is in Unyson+ → POS Sync → Log.

Start at the log

Each event records what arrived, what was matched, what was applied, and what was skipped and why. Filter by connection, SKU or state.

StateMeaning
pendingAccepted, waiting on the queue. Normal for a few seconds.
appliedStock was written. The row shows before/after levels.
duplicateAlready seen. Correct behaviour — a retried webhook.
skippedDeliberately not applied. The reason is on the row.
failedApplying threw after all retries. Needs attention.

A skipped event is not a bug in itself — test mode, an unmatched SKU and a stale count all skip correctly. Read the reason before treating it as a fault.

Nothing arrives at all

Work outwards from the site.

  1. Is the endpoint reachable?

    curl https://example.com/wp-json/unysonplus-pos/v1/ping -H "X-UPOS-Key: upos_live_…"

    No response → the REST API is blocked. Security plugins and some hosts block unknown /wp-json/ namespaces by default; allowlist unysonplus-pos.

  2. Is the extension active? POS Sync ships inactive. Check Unyson+ → Extensions.

  3. Is the connection live? A connection in test mode logs everything and writes nothing — which looks exactly like "not working" if you have forgotten.

  4. Is the sender pointed at the right URL? Through a tunnel, the URL changes every time a quick tunnel restarts. A stale URL in the vendor dashboard is the most common dev-time cause.

  5. Check the vendor's delivery log. Square, Clover and Zettle all show attempts and response codes. If the vendor reports 200s and your log is empty, the requests are reaching a different site than you think.

401 — signature rejected

In order of likelihood:

  1. The body was re-serialized after signing. Sign the exact bytes you send. Building the JSON twice — once to sign, once to send — reorders keys or changes whitespace and invalidates the signature. Serialize once, into a variable, and use that variable for both.
  2. The signing string is wrong. It is {timestamp}\n{raw_body} — a literal newline between, nothing else. No URL, no method.
  3. Clock drift. More than 5 minutes from server time is rejected. Check the sender's clock; a virtual machine resuming from suspend is a classic offender.
  4. Wrong secret. Secrets are shown once. If in doubt, rotate and reconfigure — you cannot read the existing one back.
  5. A proxy modified the body. Some WAFs re-encode JSON. Compare the raw body length against Content-Length in the log.

Stock is wrong

Wrong by a consistent amount, always the same product

Almost always a matching problem — two products sharing a SKU, or a variable product whose parent and variation both carry one. Search the log for the SKU; if two store_ref values appear, that is the answer.

Wrong after an outage

Expected, and what reconciliation is for. Check the Reconciliation report (POS Sync → Health) and use Resync to pull authoritative counts from the POS.

Wrong and drifting steadily

Something else is writing stock: another plugin, a CSV importer, a supplier feed. The log shows what POS Sync applied; if the current level does not match the last applied row and no POS event sits between, the write came from outside.

Rewound to an older value

A stale absolute count was applied. This should be impossible — absolute counts older than the last applied count are skipped — so it points at either clock skew on the till (check the skew warning on the connection) or a POS sending occurred_at in local time with no offset.

:::warning Always send an explicit offset "occurred_at": "2026-09-01T14:32:11" with no Z or +01:00 is ambiguous, and ambiguity in the ordering key is what causes rewinds. Every timestamp must carry an offset. :::

Items land in the Unmatched queue

Working as designed — POS Sync will not guess. Common causes:

  • No SKU on the WordPress product. The usual one.
  • SKU mismatch. Trailing spaces, case differences, or a hyphen where the POS has an underscore. The queue shows both values side by side.
  • Variable product with the SKU on the parent only. Woo matches on variations; put the SKU there.
  • Genuinely not a stock item — a bag charge, a service, a discount line. Mark it ignored once and it stops appearing.

The queue is stalled

pending events piling up:

  1. Is Action Scheduler running? With WooCommerce, check WooCommerce → Status → Scheduled Actions. A backlog there affects far more than POS Sync.
  2. Is WP-Cron disabled? DISABLE_WP_CRON without a real system cron leaves nothing to run the queue. Add a server cron hitting wp-cron.php.
  3. Are jobs failing and retrying? A failed event carries the error. A cart fatal usually shows here first.

Refunds do not restock

  • "restock": false in the payload — deliberate, for damaged goods.
  • Partial refund where the driver reports partial_refunds: false. Check the capability row on Health.
  • The refund references a sale_external_id never received. It is held for 24 hours, then applied as a standalone stock movement; the log says which happened.

Duplicate orders in the store

If sales appear twice as orders but stock moved only once, the second is not a POS Sync duplicate — the ledger's UNIQUE index makes that impossible. Look for the till sending different external_id values for retries of the same sale. That is a POS misconfiguration: the idempotency key must be stable across retries.

Search the log for two applied events with the same total and occurred_at but different external_id values to confirm.

The diagnostic report

Unyson+ → POS Sync → Health → Diagnostic report.

Three drivers — FluentCart, SureCart and Clover — plus the CSV importer were written against documented APIs and have never run against a live install. They are labelled experimental wherever they appear. If one does not work for you, this report is what turns a five-message conversation into a five-minute fix.

It collects, automatically:

  • POS Sync, WordPress, PHP and MySQL versions, and the schema version against the expected one
  • every store driver and provider, whether each is available, and exactly which expected function was missing when one is not
  • the capabilities each available driver declares
  • connections by number — type, mode, event count, clock skew, when each was last heard from
  • the last failures and skips, with their reasons

It is safe to paste in public

That is a design constraint, not a hope. Never included:

  • API keys, secrets, OAuth tokens or webhook signature keys — not even truncated, since a prefix plus a merchant id often identifies an account
  • customer names, emails or addresses
  • connection names, which are frequently a shop or a person ("Priya's till") — they are numbered
  • your site URL, unless you tick the box

Event payloads are the awkward case: they are the most useful thing in a bug report and the most likely place for personal data. So they are summarised structurally — types, counts, SKUs and error reasons — rather than included verbatim. The test suite asserts the absence of each secret by value, so this cannot quietly regress.

Select the box, copy it, and open an issue on the extension's tracker with what you expected to happen.

"This driver is installed but unavailable"

That is the experimental-driver safety net doing its job, and the message names the specific functions it expected and did not find.

It means the cart is present but its API is not the one the driver was written against — a version difference, most likely. Nothing is broken and nothing is lost: events keep being recorded and resolve to no_store_driver, so once a working driver exists they can be re-queued from the Log tab. Send the diagnostic report; the missing-function list is usually the whole fix.

Getting help

The fastest route is the diagnostic report — it already contains everything below and cannot forget any of it.

If you would rather write it by hand, include:

  • The event id from the log (not a screenshot of stock).
  • The connection type and mode.
  • The store driver and its capability row from Health.
  • Whether the event shows as applied, skipped or failed, and the recorded reason.

The stored payload is the verbatim request, so an event id is usually enough to reproduce the whole thing in the Virtual Terminal.