Tender POS webhook subscriber for Medusa

About the Medusa integration

On this page

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

  1. Download and copy the files. Download the subscriber source archive, extract it, then copy its src/ directory into your Medusa project's src/, preserving the relative paths above:

    • lib/tender-pos-webhook.tssrc/lib/tender-pos-webhook.ts
    • subscribers/*.tssrc/subscribers/*.ts

    If you already have a src/lib/ file with a different product.ts / order.ts / etc. in src/subscribers/, rename ours (e.g. tender-pos-product.ts) — Medusa loads every file in src/subscribers/ regardless of its name, so this is purely to avoid a naming collision with your own code.

  2. Set the environment variables. Add these wherever the rest of your Medusa environment lives:

    Variable Required Value
    POS_WEBHOOK_URL yes The full URL of Tender POS's Medusa webhook endpoint: https://api.tenderpos.io/webhooks/commerce/medusa.
    POS_WEBHOOK_SECRET yes 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_HANDLE only 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 .env file your process manager loads, or your container/orchestration platform's own config (Docker Compose environment: / env_file, Kubernetes Secret/ConfigMap, a systemd EnvironmentFile). 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.

  3. 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 inside tender-pos-webhook.ts computes this automatically if your environment already exposes the backend's public URL as BACKEND_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_HANDLE explicitly 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 is https://medusa.example.com, the handle is medusa.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_account and 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.

  4. 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

  1. Check the boot log. On startup, Medusa logs each subscriber it registers. Look for lines mentioning tender-pos-product, tender-pos-order, etc. (the subscriberId set in each file's config) — if none appear, the files aren't in src/subscribers/ or didn't load (check for a startup error above that line).

  2. Trigger a test mutation. In the Medusa Admin dashboard, edit any product's title and save. This fires product.updated.

  3. 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 no POS_WEBHOOK_ACCOUNT_HANDLE is set (step 3).
    • [tender-pos-webhook] ... rejected with 401 ...POS_WEBHOOK_SECRET differs from Tender POS, or this Medusa app is still running an older body-only subscriber. An unknown account handle normally receives a successful ignored_unknown_account response 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).
  4. 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 resolve fulfillment.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-item vs inventory-level are 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 with POS_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-level id — 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_SECRET requires 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/detached events can overlap workflow events and cause duplicate delivery. Discount/location changes therefore reconcile on full sync; variant price writes are covered by product-variant.updated.