# Tender POS webhook subscriber for Medusa

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](#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](/docs/providers/commerce/medusa/webhook-subscriber/download),
   extract it, then copy its `src/` directory into your Medusa project's
   `src/`, preserving the relative paths above:
   - `lib/tender-pos-webhook.ts` → `src/lib/tender-pos-webhook.ts`
   - `subscribers/*.ts` → `src/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](/docs/providers/commerce/medusa/backend-setup)
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](#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:

  ```text
  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](/docs/providers/commerce/medusa/backend-setup))
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](#non-goals-kept-deliberately-simple)). 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`.
