Guides → Setup

Medusa

Medusa setup

Open live Medusa demo

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

Written against
Source 0.9.7 · current public npm package 0.9.7 · Medusa v2 Payment Module
Who owns what
Medusa owns the storefront, cart, order, and fulfilment flow. Open Border owns tax and duty, payment intents, entity routing, and the ledger.

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.

The two keys
KeyWhere it goesWhat 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.

Sanitized Open Border dashboard capture showing where to create and manage Test API keys, with the key value masked.
Sanitized dashboard capture: choose Test mode, create the key, and keep the masked secret on the Medusa server only.

Step 3

Install

Install the provider and the browser checkout element
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.

Sandbox environment
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.

medusa-config.ts
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,
            },
          },
        ],
      },
    },
  ],
};
Copy-ready Claude Code prompt
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.

1. Build the Open Border line items from the Medusa cart
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,
}));
2. Quote duty and tax on the server
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.

Full running Medusa storefront with a hoodie product beside checkout, Germany as the shipping destination, and USD as the independent charge currency, showing tax, duty, and total.
Running demo: Germany drives destination tax and duty while USD independently selects the charge currency and entity route.
3. Collect the card in the browser with the publishable key
<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.

4. Put the token and quote into the Medusa payment session
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 },
};
Required payment-session fields
FieldWhat it doesSet 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. 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. 2 Confirm the amounts reconcile The provider derives the merchandise subtotal from sum(quantity * unit_amount), because Medusa’s amount is 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. 3 Decline Repeat with card 4000 0000 0000 0002 and confirm your checkout surfaces a safe error and does not place a paid order.
  4. 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_id
  • openborder_status
  • entity — the Open Border entity resolved from the charge currency
  • amount_breakdown — subtotal, shipping, tax, duty, total, and currency
  • tax_quote_id
  • client_secret, when the payment flow returned one

Step 7

Go live

  1. 1 Swap both keys together Move to sk_live_ on the server and pk_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. 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. 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. 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. 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. 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

Symptoms and what to check
SymptomWhat to check
The card element does not renderA 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_methodThe 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 idSupply 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 reconcileAlmost 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 createdA 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 outThe 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_supportedExpected 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 only pk_ 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.