Guides → Setup
Medusa
Medusa setup
Open live Medusa demoInstall the Open Border payment and tax provider in a Medusa v2 store, register it, wire the quote-then-pay checkout flow, take a sandbox card payment, and switch to live.
Availability
Resources and readiness
- Public Public capped Sandbox storefrontThe public runtime uses the current tax contract and has reconciled its accepted payment, cancellation, and signed callback lifecycle under server-side caps.
- Public Medusa sandbox onboarding walkthroughInstall, configure, and transact walkthrough; package webhook handling remains not supported, so use polling fallback.
Step 1
Before you start
- An Open Border merchant account whose KYB review has been approved.
- A Medusa v2 store you can add modules to and deploy.
- A charge currency of USD, GBP, EUR, CAD, or AUD. The charge currency selects the Open Border legal entity; the shipping destination independently drives duty and tax.
- An HS tariff code available per product or variant, in metadata you can read at checkout. If your catalogue does not have them, classify the products through Open Border first and persist the codes back.
- HTTPS between the browser and your Medusa server, because the browser posts a payment token to it.
Step 2
Get your test keys
This integration needs two keys, and they are not interchangeable. Getting them the wrong way round is the most common setup mistake here.
| Key | Where it goes | What it can do |
|---|---|---|
Secret key sk_test_… | The Medusa server environment only. | Quotes duty and tax, and creates payment intents. It moves money, so it must never reach browser code, HTML, analytics, or error telemetry. |
Publishable key pk_test_… | Browser code. It is public by design. | Authenticates one read-only configuration fetch so the card element can render. It cannot move money. |
Both keys must be on the same rail: sk_test_ with pk_test_, and sk_live_ with pk_live_. A test key reaches only test rails and a live key only live rails.
Step 3
Install
npm install @open-border/medusa-payment-openborder @open-border/js
Both packages are public on npm under the @open-border scope. A normal npm install works without an .npmrc or registry token.
Step 4
Configure
Put the keys in your Medusa server environment. OPENBORDER_API_URL is optional outside local development, because the client picks the API host from the key’s rail — pass it explicitly for local or internal staging.
OPENBORDER_API_URL=https://api-sandbox.openborderpayments.com
OPENBORDER_API_KEY=sk_test_...
OPENBORDER_PUBLISHABLE_KEY=pk_test_...
Then register the provider with the Medusa v2 Payment Module. The provider identifier is openborder; with the provider id also set to openborder, Medusa stores the resolved id as pp_openborder_openborder.
module.exports = {
modules: [
{
resolve: '@medusajs/medusa/payment',
options: {
providers: [
{
resolve: '@open-border/medusa-payment-openborder/providers/openborder',
id: 'openborder',
options: {
apiKey: process.env.OPENBORDER_API_KEY,
baseUrl: process.env.OPENBORDER_API_URL,
},
},
],
},
},
],
};
Implement the Open Border integration in this Medusa v2 store.
Requirements:
- Quote destination-based tax and duty on the server before allowing payment. Do not mount or submit payment against a missing or stale quote.
- Keep the Open Border secret key server-only. Never serialize it into browser code, HTML, logs, analytics, or error telemetry.
- Use the shipping destination for tax and duty. Treat billing as record/verification data, not routing data.
- Preserve the cart charge currency. Route the Open Border entity from that currency independently of the shipping destination.
- Expose only the matching publishable key to the browser checkout element.
- Derive and reuse stable idempotency keys from persisted Medusa workflow identity across retries. Never generate a fresh key after an unknown outcome.
- Persist the payment intent id, status, resolved entity, amount breakdown, tax quote id, and any returned client secret in Medusa order metadata for reconciliation.
- The current Medusa package does not project webhook callbacks. Do not claim callback support: poll or perform an explicit payment-intent status lookup until callback projection is implemented.
Add focused tests for quote-before-pay, secret isolation, destination/currency routing, retry idempotency, metadata persistence, and the polling fallback.
Now wire the checkout. The flow is quote-before-pay, in four moves: build the line items, quote duty and tax on the server, collect the card in the browser, then hand the token and the quote id back to the payment session.
const openBorderLineItems = cart.items.map((item) => ({
sku: item.variant?.sku,
description: item.title,
quantity: item.quantity,
unit_amount: item.unit_price,
hs_code: item.metadata?.hs_code,
}));
const {
createOpenBorderApiClient,
OpenBorderTaxProvider,
} = require('@open-border/medusa-payment-openborder');
const openBorder = createOpenBorderApiClient({
apiKey: process.env.OPENBORDER_API_KEY,
baseUrl: process.env.OPENBORDER_API_URL,
});
const taxProvider = new OpenBorderTaxProvider(openBorder);
const quote = await taxProvider.getTaxLines(openBorderLineItems, {
destination_country: shippingAddress.country_code.toUpperCase(),
destination_region: shippingAddress.province,
destination_postal_code: shippingAddress.postal_code,
ship_from_country: 'US', // your dispatch origin
currency: cart.currency_code.toUpperCase(),
shipping_amount: cart.shipping_total,
customer: { email: cart.email },
});
US and CA destinations require at least one non-whitespace destination_region or destination_postal_code. Forward the Medusa shipping address values as shown; other destinations may omit both.
quote.amount_breakdown.total is the landed-cost total to show the buyer before you collect a card — as an integer minor-unit value (16030, not 160.30), so format it before rendering it yourself; the checkout element formats it for you when you pass it as amount. quote.tax_quote_id must travel with the payment session so Open Border can revalidate the quote when it creates the payment intent.
<div id="openborder-checkout"></div>
<script src="https://unpkg.com/@open-border/js"></script>
<script>
const checkout = OpenBorder(window.OPENBORDER_PUBLISHABLE_KEY, {
apiBaseUrl: window.OPENBORDER_API_URL,
});
checkout.mount('#openborder-checkout', {
currency: quote.amount_breakdown.currency,
amount: quote.amount_breakdown.total,
billingDetails: {
email: cart.email,
name: cart.shipping_address?.first_name,
address: cart.shipping_address,
},
onSuccess: async ({ paymentMethodId }) => {
await fetch('/store/checkout/openborder-payment-method', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cart_id: cart.id,
payment_method: paymentMethodId,
tax_quote_id: quote.tax_quote_id,
}),
});
},
onError: (message) => console.error(message),
});
</script>
If your storefront sends a Content-Security-Policy, it must allow the payment processor’s browser SDK or the element silently fails to render. Allow script-src and frame-src for https://js.stripe.com, plus the wallet origins if you offer Apple Pay or Google Pay.
const sessionData = {
cart_id: cart.id,
payment_method: paymentMethodId,
openborder_tax_quote_id: quote.tax_quote_id,
amount_breakdown: quote.amount_breakdown,
shipping_amount: cart.shipping_total,
merchant_reference: cart.id,
customer: {
email: cart.email,
name: `${cart.shipping_address.first_name} ${cart.shipping_address.last_name}`.trim(),
},
billing_address: toOpenBorderAddress(cart.billing_address ?? cart.shipping_address),
shipping_address: toOpenBorderAddress(cart.shipping_address),
line_items: openBorderLineItems,
metadata: { medusa_cart_id: cart.id },
};
| Field | What it does | Set it to |
|---|---|---|
cart_id |
A persisted Medusa identity that stays stable across retries of the same payment session. session_id or medusa_session_id also work. |
Required. Intent creation fails closed without a stable id, because that id is what makes a retry idempotent instead of a second charge. |
payment_method |
The token from the browser element. openborder_payment_method is accepted too. |
Required before an intent can be created. Call the provider without it and it returns a pending session flagged openborder_requires_payment_method. |
openborder_tax_quote_id |
The server-issued quote id, revalidated server-side. tax_quote_id also works. |
Required on every charge. The provider fails closed without it, before any provider call — run the tax provider on the cart first. |
amount_breakdown |
The server-issued quote breakdown, kept exactly as returned so the provider can reconcile Medusa’s payment total before authorising. | Required. Keep it in minor units — do not reformat or recompute it. |
line_items |
The same item fingerprint you quoted with. | Required, and must match the quote. |
shipping_amount |
Shipping, when it was included in the quote. | Required when the quote included shipping. |
customer.email |
The buyer’s email, used on the receipt and for the payment record. | Required. |
billing_address, shipping_address |
Billing is recorded for address verification and audit; it routes nothing. Shipping drives duty and tax. | Both required, each with at least line1 and country. |
merchant_reference |
Usually the Medusa cart, order, or payment collection id. | Optional. Omitted, the provider uses the required stable session identity. |
Step 5
Connect webhooks
Asynchronous reconciliation matters because payment outcomes can change after the buyer leaves checkout: an authorisation may later capture or fail, a refund may finish asynchronously, and a dispute can open or resolve days later. Without a verified callback projection or an explicit status read, Medusa can show stale paid, refunded, or disputed state and fulfil or support against the wrong money outcome.
- Payment intents:
payment_intent.created,payment_intent.succeeded,payment_intent.captured,payment_intent.canceled,payment_intent.failed. - Refunds:
refund.succeeded,refund.failed. - Disputes:
dispute.opened,dispute.evidence_submitted,dispute.won,dispute.lost.
The Open Border platform emits those event families, but callback projection into the Medusa package is currently unsupported. Until it lands, polling or an explicit payment-intent status lookup is the current supported fallback. The provider’s status lookup reads current Open Border server truth and owns no local money state, so call it whenever you need to know where a payment actually stands.
- Read payment status from Open Border before you treat an order as paid in any workflow that matters — do not infer it from the fact that a session was created.
- Treat the Open Border payment intent, transaction snapshot, and amount breakdown as the authoritative money record. Medusa is not the source of truth for money here.
- If a create call fails at the transport layer, the outcome is unknown: retry with the same idempotency key rather than building a new request. The provider derives its keys from persisted workflow identity, not from a volatile per-attempt key, so a plain retry of the same operation is safe.
Step 6
Run a test payment
-
1
Successful payment
Run a cart through with card
4242 4242 4242 4242, any future expiry, any CVC. Use test cards from the payment processor account attached to the resolved Open Border entity, and confirm the publishable key is on the same test rail as the secret key. -
2
Confirm the amounts reconcile
The provider derives the merchandise subtotal from
sum(quantity * unit_amount), because Medusa’samountis the full payment-collection total including shipping and the quoted duty and tax. Before authorising, it checks the retained quote breakdown’s total, subtotal, shipping, and currency against those derived values — a cart that changed after quoting is rejected rather than mis-charged. -
3
Decline
Repeat with card
4000 0000 0000 0002and confirm your checkout surfaces a safe error and does not place a paid order. - 4 Persist the receipt fields The package does not write these into Medusa order metadata for you. Do it in your own order-completion workflow, or support staff will have no way to reconcile a Medusa order to an Open Border transaction.
openborder_payment_intent_idopenborder_statusentity— the Open Border entity resolved from the charge currencyamount_breakdown— subtotal, shipping, tax, duty, total, and currencytax_quote_idclient_secret, when the payment flow returned one
Step 7
Go live
-
1
Swap both keys together
Move to
sk_live_on the server andpk_live_in the browser at the same time. A mismatched pair fails rather than half-working, but swapping only one is still the easiest way to waste an afternoon. -
2
Point at the production API
Set
OPENBORDER_API_URL=https://api.openborderpayments.com, or drop the variable entirely and let the client resolve the host from the live key’s rail. -
3
Audit what reaches the browser
Grep your built storefront bundle and your server-rendered HTML for
sk_before you deploy. This is the one mistake in this integration you cannot walk back. - 4 Confirm your CSP allows the processor SDK A production CSP that blocks the browser SDK makes the card element silently fail to render — it does not raise an obvious error.
- 5 Confirm every product carries an HS code A quote cannot be created for a line item without one. Check the real catalogue, not a fixture.
- 6 Take one small real payment and refund it Confirm the intent, the breakdown, and your persisted receipt fields all look right before opening checkout to buyers.
Step 8
Troubleshooting
| Symptom | What to check |
|---|---|
| The card element does not render | A Content-Security-Policy or ad blocker is blocking the payment processor’s browser SDK, or the publishable key is on the wrong rail. Check the browser console. |
The session stays pending with openborder_requires_payment_method | The provider was called before a payment token existed. Update the session with the token from the browser element and the provider will create the intent. |
| Intent creation fails closed on a missing id | Supply a stable cart_id, session_id, or medusa_session_id. A volatile per-attempt value is not usable as payment identity. |
| Payment session creation fails with “a tax quote is required” | Expected: every charge needs a server-issued quote. Run the Open Border tax provider on the cart and put tax_quote_id (and the returned amount_breakdown) into the session data before payment. |
| The total does not reconcile | Almost always units. Every money input is a Medusa major-unit value on the current package — passing minor units (a 0.6.x habit) inflates values 100×. Also confirm the server-issued amount_breakdown goes through unchanged. |
| A quote cannot be created | A line item is missing its HS code. Classify the product through Open Border and persist the code to the product or variant metadata. |
| The transport call timed out | The outcome is unknown. Retry the same operation so the same idempotency key is reused — never rebuild the request with fresh identity. |
Webhook handling returns not_supported | Expected in this release. Read payment status from Open Border instead of waiting for a callback. |
Step 9
Upgrade and remove
Upgrade with npm as normal, but read the package changelog first, because two releases changed the input contract. At 0.7.0 this package’s money-field conventions changed, and a unit-convention change is not something a test suite in your storefront will necessarily catch. At 0.8.0 a tax quote became required on every charge: a cart that reaches payment-session creation without a tax_quote_id now fails closed instead of charging an untaxed total, so run the tax provider on the cart first. Update the payment-session and tax-provider inputs in the same change as the version bump.
To remove the integration, unregister the provider from medusa-config.ts, delete the checkout wiring, and uninstall both packages. Resolve every non-terminal payment first — Open Border remains the authoritative record for anything already charged, and removing the provider does not release an outstanding authorisation.
- Keep
sk_keys server-side, and expose onlypk_keys to browser code. - Use test keys and the sandbox host until production activation is approved.
- Send payment tokens from the browser to your backend over HTTPS only.
- Never log a full API key, a payment method id, card data, or a customer address.