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.
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.
Connect the backend
Enter the Medusa API URL, secret Admin API token, and webhook signing secret. Medusa uses no OAuth scopes.
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.
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.
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.
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.
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/deletedInstall steps
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.
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.
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_URLRequiredThe 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_SECRETRequiredThe 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 neededOverrides the account-handle header. Leave unset unless the previous step says otherwise.
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.
# Medusa API URL entered in Tender POS
https://medusa.example.com
# resulting account handle
medusa.example.comignored_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.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.
[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 upworker 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.tsproduct.created · product.updated · product.deletedProductWorkflowEventsproduct-variant.tsproduct-variant.created · .updated · .deletedProductVariantWorkflowEventsproduct-option.tsproduct-option.created · .updated · .deletedProductOptionWorkflowEventsinventory-item.tsinventory.inventory-item.created · .updated · .deletedInventoryEventsinventory-level.tsinventory.inventory-level.created · .updated · .deletedInventoryEventsorder.tsorder.placed · .updated · .canceled · .completed · .archived · .fulfillment_created · .fulfillment_canceled · .return_requested · .return_received · .claim_created · .exchange_created · .transfer_requested · order-edit.requested · .confirmed · .canceledOrderWorkflowEvents, OrderEditWorkflowEventspayment.tspayment.captured · payment.refundedPaymentWorkflowEventsfulfillment.tsshipment.created · delivery.createdFulfillmentWorkflowEventscustomer.tscustomer.created · customer.updated · customer.deletedCustomerWorkflowEventsEvent 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.
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.
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
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.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.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_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.
More e-commerce platforms
All integrations
Ready to connect Medusa?
Create your Tender POS account, connect your Medusa credentials, and install the backend companion.

