Integrations / Medusa

Medusa

Medusa stays your source of truth. Tender POS mirrors its catalog, inventory, customers, promotions, locations, and orders for fast scanning, then writes POS sales and supported payment refunds back to Medusa.

CategoryE-commerce platform
Initial syncPaginated Admin API
Kept current bySigned companion events + full sync

How Tender POS connects to Medusa

Connect with a public backend URL and secret Admin API token. Near-real-time updates require the Tender POS backend companion in your Medusa app.

01

Connect the backend

Enter the Medusa API URL, secret Admin API token, and webhook signing secret. Medusa uses no OAuth scopes.

02

Install the backend companion

Deploy the Tender POS subscriber files in your Medusa app and set the POS_WEBHOOK_* environment variables. Full sync works without them; near-real-time updates do not.

03

Run a full sync

Tender POS reads locations, catalog, inventory, customers, promotions, and orders through paginated Admin API calls. Medusa has no bulk-export API.

What moves between Medusa and the counter.

Paginated Admin API reads build the local mirror. Signed companion events keep supported resources current, while full sync reconciles promotions, stock locations, and missed events. POS sale and refund workflows write supported activity back.

Synced to your counter

Products and variants

Titles, descriptions, SKUs, barcodes, options, images, and supported inventory links. Tracked variants must map to one inventory item at quantity one.

Variant prices

Currency-aware variant prices from Medusa price sets and regions.

Inventory by location

Available inventory per stock location for supported single-component tracked variants. Unsupported inventory kits fail closed instead of risking an incorrect stock level.

Customers and addresses

Names, email addresses, phone numbers, saved addresses, and metadata.

Order history

Order totals and statuses, customers, locations, line items, payments, fulfillments, and refunds.

Promotions and codes

Active code-based fixed or percentage promotions supported by Tender POS. Other promotion shapes remain in Medusa.

Stock locations

Medusa stock locations, address data, and linked sales-channel context.

Pushed back to Medusa

In-person sales

Completed or partially paid POS sales become Medusa orders through a recoverable draft-to-order workflow, with payment and completion state recorded.

Customer details

A customer can be selected or created while a new POS order is built. Tender POS does not change the customer on an existing Medusa order.

Payment refunds

Tender POS refunds an amount against one captured Medusa payment. Line allocations remain in Tender POS; Medusa line-item refunds and restocking are not supported.

Inventory from sales

For supported linked variants, Medusa applies its normal inventory movement when the POS order is created.

Scanning doesn't wait on Medusa.

Barcode lookups read Tender POS's local mirror instead of calling the Medusa API, so a scan answers in under 100 ms even when the backend is slow. Sale and refund workflows retry when Medusa becomes reachable again.

Scanlocal mirror · 68 ms
Linen Overshirt — M8712345 678904 · Amsterdam€89,95
In stock · 4 here2 Rotterdam · 1 incoming
Add to sale
Developer guideVerified against Medusa 2.13.6

Install the webhook subscriber

For the developer who maintains your Medusa backend. This is a companion module you run inside your own Medusa application.

Medusa doesn't expose webhooks over its Admin API the way Shopify does, so Tender POS has no other way to learn that your catalog, inventory, orders, or customers changed. Without this code running in your app, Tender POS only sees changes on the next full sync — not in near-real time.

Version pin

Written and verified against Medusa 2.13.6 — @medusajs/framework, @medusajs/medusa, @medusajs/utils, and @medusajs/core-flows, all at 2.13.6. On a different minor version, re-verify the event names below first — Medusa has renamed event constants between versions before.

What it installs

Nine subscriber files plus one shared helper. No new dependencies, no migrations, no new API routes in your Medusa app.

your-medusa-app/src
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

01

Copy the files

Download the subscriber source archive and extract it. Then copy webhook-subscriber/src/ into your Medusa project's src/, preserving the relative paths above.

shell
cp -R webhook-subscriber/src/lib/tender-pos-webhook.ts \
  your-medusa-app/src/lib/
cp -R webhook-subscriber/src/subscribers/*.ts \
  your-medusa-app/src/subscribers/

Already have a src/subscribers/product.ts? Rename ours to tender-pos-product.ts — Medusa loads every file in that folder regardless of its name.

02

Set the environment variables

Add these wherever your Medusa process reads its environment — your host's environment-variable settings, a .env file your process manager loads, or your container platform's own config. Some hosts restart the process automatically after a change; others need an explicit redeploy.

POS_WEBHOOK_URLRequired

The full URL of Tender POS's Medusa webhook endpoint: https://api.tenderpos.io/webhooks/commerce/medusa. Ask your Tender POS contact to confirm the current value.

POS_WEBHOOK_SECRETRequired

The exact value entered as “Webhook signing secret” when Medusa was connected in the Tender POS dashboard. If the two don't match character-for-character, every delivery is rejected with a 401.

POS_WEBHOOK_ACCOUNT_HANDLEOnly if needed

Overrides the account-handle header. Leave unset unless the previous step says otherwise.

03

Verify the account handle

Tender POS matches a delivery to your connection by the host of your Medusa backend's public URL — the same value stored as the Medusa API URL when you connected. The helper computes this automatically when your environment already exposes that URL as BACKEND_PUBLIC_URL; otherwise, set POS_WEBHOOK_ACCOUNT_HANDLE explicitly.

handle = host only, no scheme or path
# Medusa API URL entered in Tender POS
https://medusa.example.com

# resulting account handle
medusa.example.com
Silent failure
A wrong handle raises no error here. pos-api logs ignored_unknown_account and drops the delivery, because an unknown handle looks identical to “this merchant hasn't installed the subscriber yet.” If deliveries leave your app but nothing changes in Tender POS, check this first.
04

Deploy, then confirm it fires

Restart so the subscribers register. On boot, Medusa logs each one — look for the tender-pos-* subscriber ids. Then edit any product's title in Medusa Admin to fire product.updated. Success logs nothing, by design.

what the logs can tell you
[tender-pos-webhook] POS_WEBHOOK_URL / POS_WEBHOOK_SECRET not set — skipping product.updated delivery
  → set both in your Medusa environment (step 02)

[tender-pos-webhook] could not determine an account handle for product.updated
  → set POS_WEBHOOK_ACCOUNT_HANDLE explicitly (step 03)

[tender-pos-webhook] product.updated rejected with 401
  → POS_WEBHOOK_SECRET doesn't match, or an outdated subscriber is installed

[tender-pos-webhook] gave up delivering product.updated after 3 attempts
  → all 3 attempts failed; the next full sync catches it up
Needs a worker or shared process
Subscribers only run in a process using worker or shared MEDUSA_WORKER_MODE. A single shared-mode deployment (the default) already covers this. If your deployment splits into separate server- and worker-mode instances, deploy these files and the POS_WEBHOOK_* variables to whichever instance runs in worker or shared mode — see the backend setup guide for that split.

Events it listens to

Every name below was checked against the string constants in the installed @medusajs/utils@2.13.6, not the public docs — those lag releases.

product.ts
product.created · product.updated · product.deletedProductWorkflowEvents
product-variant.ts
product-variant.created · .updated · .deletedProductVariantWorkflowEvents
product-option.ts
product-option.created · .updated · .deletedProductOptionWorkflowEvents
inventory-item.ts
inventory.inventory-item.created · .updated · .deletedInventoryEvents
inventory-level.ts
inventory.inventory-level.created · .updated · .deletedInventoryEvents
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 · .canceledOrderWorkflowEvents, OrderEditWorkflowEvents
payment.ts
payment.captured · payment.refundedPaymentWorkflowEvents
fulfillment.ts
shipment.created · delivery.createdFulfillmentWorkflowEvents
customer.ts
customer.created · customer.updated · customer.deletedCustomerWorkflowEvents
If you extend this

Event payloads carry only an id — never the full record. product-variant.ts and inventory-level.ts therefore do an extra query.graph() lookup, because pos-api needs the variant's parent product id and the level's location and item ids.

Don't merge inventory-item and inventory-level: an item event means the item's own properties changed and isn't location-scoped, while a level event is one location's stock quantity. The mirror reconciles them differently.

Signing & retries

Every delivery is a POST to POS_WEBHOOK_URL, signed over a canonical payload — not just the raw body.

request
POST /webhooks/commerce/medusa
content-type:                application/json
x-medusa-hmac-sha256:        <base64 HMAC-SHA256 of the canonical payload below>
x-medusa-signature-version:  1
x-medusa-account-handle:     medusa.example.com
x-medusa-topic:              product.updated
x-medusa-webhook-id:         3f6c1e8a-9d2b-4e51-8a6f-7c9d2e4b1a03
x-medusa-triggered-at:       2026-07-28T09:12:44.118Z

{
  "id": "3f6c1e8a-9d2b-4e51-8a6f-7c9d2e4b1a03",
  "topic": "product.updated",
  "createdAt": "2026-07-28T09:12:44.118Z",
  "data": { "id": "prod_01J..." }
}

The HMAC covers this newline-delimited string, not the raw request body on its own — the account handle, topic, webhook id, and timestamp are all part of the signature, alongside a digest of the body.

the exact bytes that get HMAC-signed
tender-pos-medusa-webhook/v1
medusa.example.com
product.updated
3f6c1e8a-9d2b-4e51-8a6f-7c9d2e4b1a03
2026-07-28T09:12:44.118Z
<sha256 hex digest of the raw request body>

5-minute replay window

pos-api checks that x-medusa-triggered-at is within five minutes of server time, on top of the HMAC check. Retries reuse the first attempt's timestamp, so a very slow retry would be rejected as stale — in practice retries finish within seconds.

3 attempts, then dropped

Medusa's Redis event bus defaults to attempts: 1, so it won't retry a throwing subscriber. The helper retries inside the HTTP call instead — waiting 250 ms, then 750 ms between attempts — and gives up immediately on a 401. It never throws back, so a Tender POS outage can't fail a product save or an order.

Troubleshooting

No tender-pos-* lines in the boot log at allFiles aren't in src/subscribers/, or a type error is stopping the app from starting. Recheck the paths from step 1 and run your project's TypeScript check — a stock Medusa project uses npm, yarn, or pnpm, not Bun, so that's typically npx tsc --noEmit.
No tender-pos-* lines in the boot log, but the files are present and nothing else looks wrongThis instance is running in server mode — subscribers only execute in worker or shared mode. Deploy these files and the POS_WEBHOOK_* variables to whichever instance runs in worker or shared mode.
POS_WEBHOOK_URL / POS_WEBHOOK_SECRET not set in the logsThe environment variables aren't visible to the running process. Confirm they're set on the exact service that's actually running — hosts with separate preview or staging environments are a common place to set the variable on the wrong one.
could not determine an account handleThe environment provides no public-URL variable (BACKEND_PUBLIC_URL). Set POS_WEBHOOK_ACCOUNT_HANDLE explicitly.
rejected with 401 every timeThe secret doesn't match, or this app is still running an outdated subscriber. Re-copy the secret from the Tender POS dashboard with no stray whitespace, and reinstall the current tender-pos-webhook.ts.
Deliveries succeed here, but nothing changes in Tender POSAlmost always an account-handle mismatch — an unrecognized handle is silently ignored rather than treated as an error. Double-check the handle against the connection's Medusa API URL host.
Root events work, but option, variant, or inventory-level deletes don'tThe query.graph() lookup couldn't resolve the already soft-deleted child. Confirm the installed subscriber includes withDeleted: true, then check the Medusa log for the missing id.
Payment or shipment events arrive, but the order doesn't updateThe relation lookup couldn't resolve payment_collection.order or fulfillment.order. Confirm the event belongs to an order and that the current subscriber is installed.

Kept deliberately simple: no dead-letter queue or outbox table, no signature rotation grace period, no batching. Missed events are caught up by the next full sync rather than queued inside your app on Tender POS's behalf.

Questions about Medusa and Tender POS.

Does Medusa stay the source of truth?

Yes. Manage catalog data, prices, inventory, promotions, and online orders in Medusa. Tender POS mirrors the supported data for the counter and writes new POS orders and supported payment refunds back. Provider-side line-item refunds, refund restocking, gift cards, and multi-component inventory kits are not supported.

What happens if Medusa is slow or down?

Barcode lookup reads the local Tender POS mirror, so scanning does not wait on Medusa. Sending a sale or refund back still needs the backend to be reachable and runs in a workflow that can retry.

How do sales get back into Medusa?

Tender POS creates a Medusa draft order, validates Medusa's calculated total, converts it to an order, records the payment, and completes it when fully paid. The workflow uses durable markers so a retry does not create a duplicate order.

How is the mirror kept current?

The first sync uses paginated Admin API reads. The installed signed companion sends targeted product, inventory, customer, order, payment, and fulfillment changes. Promotions and stock locations refresh on the next full sync, which also repairs missed events.

Ready to connect Medusa?

Create your Tender POS account, connect your Medusa credentials, and install the backend companion.

Get started