Audience: the developer who maintains your Medusa backend. This is a companion module you install and run inside your own Medusa application — Tender POS has no other way to know when your catalog, inventory, orders, or customers change, because Medusa doesn't expose webhooks over its Admin API the way Shopify does. This is the whole point of it: without this code running in your app, Tender POS can only see what changed the next time it runs a full sync, not in near-real-time.
Pinned version: written and verified against Medusa 2.13.6
(@medusajs/framework, @medusajs/medusa, @medusajs/utils, @medusajs/core-flows
all at 2.13.6). If your project is on a different major/minor version,
re-verify the event names in Event names, verified
below before relying on this — Medusa's event names and container
registration keys have changed between versions before.
What this installs
Nine subscriber files plus one shared helper. Nothing else — no new dependencies, no database migrations, no new API routes in your Medusa app.
src/
├── lib/
│ └── tender-pos-webhook.ts # signs and sends every delivery
└── subscribers/
├── product.ts # product.created/updated/deleted
├── product-variant.ts # product-variant.created/updated/deleted
├── product-option.ts # product-option.created/updated/deleted
├── inventory-item.ts # inventory.inventory-item.created/updated/deleted
├── inventory-level.ts # inventory.inventory-level.created/updated/deleted
├── order.ts # order + order-edit lifecycle events
├── payment.ts # payment.captured/refunded
├── fulfillment.ts # shipment.created/delivery.created
└── customer.ts # customer.created/updated/deleted
Install steps
Download and copy the files. Download the subscriber source archive, extract it, then copy its
src/directory into your Medusa project'ssrc/, preserving the relative paths above:lib/tender-pos-webhook.ts→src/lib/tender-pos-webhook.tssubscribers/*.ts→src/subscribers/*.ts
If you already have a
src/lib/file with a differentproduct.ts/order.ts/ etc. insrc/subscribers/, rename ours (e.g.tender-pos-product.ts) — Medusa loads every file insrc/subscribers/regardless of its name, so this is purely to avoid a naming collision with your own code.Set the environment variables. Add these wherever the rest of your Medusa environment lives:
Variable Required Value POS_WEBHOOK_URLyes The full URL of Tender POS's Medusa webhook endpoint: https://api.tenderpos.io/webhooks/commerce/medusa.POS_WEBHOOK_SECRETyes The exact same value you entered as "Webhook signing secret" when you (or your merchant admin) connected Medusa in the Tender POS dashboard. If these two values don't match character-for-character, every delivery is rejected with a 401. POS_WEBHOOK_ACCOUNT_HANDLEonly if needed (see below) Overrides the account-handle header. Leave unset unless step 3 tells you to set it. Set these wherever your Medusa process reads its environment. That's typically your hosting provider's environment-variable settings, a
.envfile your process manager loads, or your container/orchestration platform's own config (Docker Composeenvironment:/env_file, KubernetesSecret/ConfigMap, a systemdEnvironmentFile). Some hosts restart the running process automatically after an environment change; others need an explicit restart or redeploy — check how your specific host handles it before assuming new values are live.Verify the account handle. Tender POS matches an incoming delivery to your merchant connection by the host of your Medusa backend's public URL — the same value already stored as the "Medusa API URL" when you connected. The
accountHandle()helper insidetender-pos-webhook.tscomputes this automatically if your environment already exposes the backend's public URL asBACKEND_PUBLIC_URL(or a host-provided equivalent the helper recognizes). A generic Medusa project sets no such variable by default, so expect to need the next paragraph.Only set
POS_WEBHOOK_ACCOUNT_HANDLEexplicitly if your environment doesn't provide a public-URL variable. It must be the exact host portion of your Medusa API URL as entered in Tender POS — e.g. if that URL ishttps://medusa.example.com, the handle ismedusa.example.com(no scheme, no path, no trailing slash).Getting this wrong doesn't show up as an error here — pos-api silently logs
ignored_unknown_accountand drops the delivery, because an unknown handle looks identical to "this merchant hasn't installed the subscriber yet." If step 4 below shows deliveries leaving your app but nothing changes on the Tender POS side, this is the first thing to check.Deploy / restart your Medusa app so the new subscribers register.
Subscribers need a worker (or shared) mode process
Medusa subscribers only execute in a process running in worker or
shared MEDUSA_WORKER_MODE. A single shared-mode deployment — the
default, and the common case for a small install — already satisfies
this, so most installs can skip straight to verification below.
If your deployment splits into separate server-mode and worker-mode
instances instead (a normal production pattern once traffic grows — see
the backend setup guide
for the server/worker split), deploy these subscriber files and the
POS_WEBHOOK_* environment variables to whichever instance(s) run in
worker or shared mode. Putting them only on the server-mode instance
means they never run — and never register a subscriber, never log
anything, never error. The server-mode instance boots cleanly, serves the
Admin API and dashboard exactly as expected, and simply never executes
anything in src/subscribers/.
If step 1 below shows no tender-pos-* boot-log lines and you've
confirmed the files are actually present, this is the first thing to
check.
Verifying it's firing
Check the boot log. On startup, Medusa logs each subscriber it registers. Look for lines mentioning
tender-pos-product,tender-pos-order, etc. (thesubscriberIdset in each file'sconfig) — if none appear, the files aren't insrc/subscribers/or didn't load (check for a startup error above that line).Trigger a test mutation. In the Medusa Admin dashboard, edit any product's title and save. This fires
product.updated.Watch the logs for the delivery. You should see either nothing (the happy path logs nothing on success, by design) or one of:
[tender-pos-webhook] POS_WEBHOOK_URL / POS_WEBHOOK_SECRET not set — skipping ...→ environment variables aren't set where the app can see them (step 2).[tender-pos-webhook] could not determine an account handle ...→ no public-URL variable (BACKEND_PUBLIC_URL) and noPOS_WEBHOOK_ACCOUNT_HANDLEis set (step 3).[tender-pos-webhook] ... rejected with 401 ...→POS_WEBHOOK_SECRETdiffers from Tender POS, or this Medusa app is still running an older body-only subscriber. An unknown account handle normally receives a successfulignored_unknown_accountresponse instead of 401.[tender-pos-webhook] ... delivery attempt N/3 failed with <status>/... threw: ...→ network or server error; it retries twice more automatically (see Retry behavior).[tender-pos-webhook] gave up delivering ... after 3 attempts→ all three attempts failed; the event is lost (see below).
Confirm on the Tender POS side by asking whoever manages the integration to check that the product's mirrored title updated, or to look up the delivery by its event id in
commerce_webhook_event.
Event names, verified
Every event name below was checked against the literal constants and workflow
emissions in the installed Medusa 2.13.6 packages, not copied from an
example list. The test suite also compares this exact 37-topic manifest
against every subscriber config, parser route, and queue-processing case. If
you run a different Medusa version, re-audit the installed
@medusajs/utils, @medusajs/core-flows, and @medusajs/medusa sources
before trusting this table:
| File | Events | Verified against |
|---|---|---|
product.ts |
product.created, product.updated, product.deleted |
ProductWorkflowEvents |
product-variant.ts |
product-variant.created, product-variant.updated, product-variant.deleted |
ProductVariantWorkflowEvents |
product-option.ts |
product-option.created, product-option.updated, product-option.deleted |
ProductOptionWorkflowEvents |
inventory-item.ts |
inventory.inventory-item.created, .updated, .deleted |
InventoryEvents |
inventory-level.ts |
inventory.inventory-level.created, .updated, .deleted |
InventoryEvents |
order.ts |
order.placed, .updated, .canceled, .completed, .archived, .fulfillment_created, .fulfillment_canceled, .return_requested, .return_received, .claim_created, .exchange_created, .transfer_requested; order-edit.requested, .confirmed, .canceled |
OrderWorkflowEvents and OrderEditWorkflowEvents |
payment.ts |
payment.captured, payment.refunded |
PaymentWorkflowEvents and the capture/refund workflows |
fulfillment.ts |
shipment.created, delivery.created |
FulfillmentWorkflowEvents and shipment/delivery workflows |
customer.ts |
customer.created, customer.updated, customer.deleted |
CustomerWorkflowEvents |
Four things worth knowing if you're extending this:
- Event payloads only carry an id — Medusa's own docs and every core
workflow confirm that these events contain identifiers, not full records.
The base product/customer/order events carry
{ id }; the related order events may carry{ order_id }. The order subscriber normalizes both. - Child and relationship events must refetch their owning root.
Product-option and product-variant events resolve their parent product.
Payment events resolve
payment_collection.order.id; shipment/delivery events resolvefulfillment.order.id. Tender POS then refetches the full product or order graph instead of trying to patch a partial child payload. - Deletes need soft-deleted graph reads. Product-option,
product-variant, and inventory-level delete events are emitted after the
row is soft-deleted. Their resolvers deliberately set
withDeleted: true; otherwise those callbacks silently lose the parent/location identity. inventory-itemvsinventory-levelare genuinely different things. An inventory-item event fires when the item's own properties change (tracked toggled, SKU/cost edited) — it is not location-scoped. An inventory-level event fires on a stock quantity change at one specific location. Don't merge these into one subscriber; pos-api's mirror reconciliation handles them differently (a level event updates one location's quantity, an item event re-checks every location the mirror already knows for that item).
Signing scheme
Every delivery is a POST to POS_WEBHOOK_URL with:
Body:
{ "id": "<uuid>", "topic": "<event name>", "createdAt": "<ISO 8601>", "data": {...} }x-medusa-signature-version:1. Missing/unknown versions are rejected.x-medusa-hmac-sha256: base64-encoded HMAC-SHA256, keyed withPOS_WEBHOOK_SECRET, of this exact newline-delimited canonical value:tender-pos-medusa-webhook/v1 <lowercase account handle> <topic> <webhook id> <triggered-at ISO timestamp> <lowercase SHA-256 hex digest of the exact raw body bytes>This authenticates both the raw body and the routing/dedupe identity. It is intentionally not compatible with the earlier body-only signature.
x-medusa-account-handle: your Medusa backend's public host (see step 3).x-medusa-topic: the event name, duplicated from the body for routing.x-medusa-webhook-id: the same uuid as the body envelope's top-levelid— this canonically signed value is what pos-api dedupes replays on.x-medusa-triggered-at: ISO 8601 timestamp of when the event fired.
pos-api also requires the body envelope's id, topic, and createdAt to
exactly match the corresponding signed headers. A valid HMAC over a
contradictory header/body identity is rejected.
Replay window
pos-api verifies the canonically signed x-medusa-triggered-at is within
5 minutes of the server's clock. A delivery outside that window is
rejected (401) even with a correct signature. The timestamp also has to match
the signed body envelope, so it cannot be changed independently to extend a
captured request's replay window. It also means:
- Your server's clock needs to be roughly correct (NTP-synced, which virtually every modern host already is).
- If a delivery fails and gets retried by this helper (see below), each retry
attempt reuses the same
createdAt/timestamp from the first attempt, not a fresh one — retrying too slowly (well past 5 minutes) means every retry will also be rejected as stale. In practice this isn't a concern: the built-in retry finishes within a few seconds.
Retry behavior
Medusa's Redis-backed event bus (@medusajs/event-bus-redis, used whenever
REDIS_URL is set in your environment — required in any production Medusa
deployment, see the
backend setup guide)
defaults every event to attempts: 1, verified against the
installed package (dist/services/event-bus-redis.js). Core commerce events
like product.updated don't override this. In plain terms: if a subscriber
throws, Medusa will not retry it for you.
Because of that, tender-pos-webhook.ts does its own retry entirely inside
the HTTP call: up to 3 attempts, a 10-second timeout per attempt, and
250ms then 750ms fallback delays. Retry-After is honored but capped at 10
seconds. Only network failures and 408, 425, 429, or 5xx responses
retry; all other 4xx responses fail fast. Every attempt reuses the exact
same body, timestamp, event id, and signature. The helper never throws back
into the subscriber, so a Tender POS outage can never fail the merchant's
underlying Medusa operation.
If all 3 attempts fail, the event is logged and dropped — there is intentionally no queue or disk-backed outbox here (see Non-goals). The next full sync (or the next successful webhook for that same resource) catches it back up.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
No tender-pos-* lines in the boot log at all |
Files aren't in src/subscribers/, or a syntax/type error is preventing the app from starting |
Re-check the file paths from step 1; run your project's TypeScript check (a stock Medusa project is npm/yarn/pnpm, not Bun: npx tsc --noEmit) |
No tender-pos-* lines in the boot log, but the files are present and nothing else looks wrong |
This instance runs in server mode; subscribers only execute in worker or shared mode |
Deploy these files and the POS_WEBHOOK_* variables to whichever instance(s) run in worker or shared mode |
POS_WEBHOOK_URL / POS_WEBHOOK_SECRET not set in logs |
Env vars aren't visible to the running process | Confirm they're set on the service/environment that's actually running the process — hosts with separate preview/staging environments are a common place to set the variable on the wrong one |
could not determine an account handle |
The environment provides no public-URL variable (BACKEND_PUBLIC_URL), and POS_WEBHOOK_ACCOUNT_HANDLE wasn't set either |
Set POS_WEBHOOK_ACCOUNT_HANDLE explicitly (step 3) |
rejected with 401 every time |
Secret mismatch, or an outdated/body-only subscriber | Re-copy the secret from the Tender POS dashboard exactly (no leading/trailing whitespace); replace tender-pos-webhook.ts with the current archive version and redeploy |
| Deliveries succeed here but nothing changes in Tender POS | Almost always an account-handle mismatch — pos-api treats an unrecognized handle as "not yet installed" and silently ignores it, not an error | Double, triple check the handle against what's stored on the connection; ask whoever manages the Tender POS side to confirm the connection's apiUrl host |
| Root events work but option/variant/inventory-level deletes do not | The query.graph(...) lookup could not resolve the soft-deleted child |
Confirm the installed subscriber matches this guide and includes withDeleted: true; then inspect the Medusa log for the exact missing id |
| Payment or shipment events arrive but the order does not update | The relation lookup could not resolve payment_collection.order or fulfillment.order |
Confirm the event belongs to an order and that the current payment.ts / fulfillment.ts subscriber is installed |
Non-goals (kept deliberately simple)
- No dead-letter queue or outbox table. If Tender POS is down for longer than the 3-attempt retry window, missed events are not queued for later — the next full sync catches up. Adding durable delivery here would mean running infrastructure inside your Medusa app on Tender POS's behalf, which isn't worth it until real usage shows it's needed.
- No signature rotation support. Rotating
POS_WEBHOOK_SECRETrequires updating it in both places (here and the Tender POS connection) at the same time; there is no dual-secret grace period. - No batching. Every event is its own HTTP request. Medusa's own event bus already coalesces bursts reasonably (e.g. a bulk product import doesn't fire one event per field), so this hasn't been a problem in practice.
- No promotion, price-list, stock-location, or region topic guessing.
The installed 2.13.6 workflows do not expose a safe dedicated lifecycle
set for these mirror roots. Generic module
created/updated/deleted/attached/detachedevents can overlap workflow events and cause duplicate delivery. Discount/location changes therefore reconcile on full sync; variant price writes are covered byproduct-variant.updated.
