Guides → Setup

Medusa

Medusa setup

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
Public npm package 0.6.0 · 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.

Step 3

Install

Install the provider and the browser checkout element
npm install @open-border/medusa-payment-openborder @open-border/js

The same packages are published to GitHub Packages under the @openborder scope if you prefer that registry. Those installs need npm configured for the scope and a token with package read access.

.npmrc — only needed for the GitHub Packages registry
@openborder:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
always-auth=true

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-demo.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,
            },
          },
        ],
      },
    },
  ],
};

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_postal_code: shippingAddress.postal_code,
  currency: cart.currency_code.toUpperCase(),
  shipping_amount: cart.shipping_total,
  customer: { email: cart.email },
});

quote.amount_breakdown.total is the landed-cost total to show the buyer before you collect a card. quote.tax_quote_id must travel with the payment session so Open Border can revalidate the quote when it creates the payment intent.

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 whenever you are charging duty and tax.
amount_breakdown The server-issued quote breakdown, kept exactly as returned so the provider can reconcile Medusa’s payment total before authorising. Required with a tax quote. 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

Until it lands, read server truth explicitly. The provider’s status lookup reads the current Open Border payment-intent status and owns no local money state, so it is safe to call 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 any quoted duty and tax. Without a quote, that total must equal subtotal plus shipping. With a quote, the provider checks the retained breakdown’s total, subtotal, shipping, and currency before authorising.
  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.
The total does not reconcileAlmost always the major-versus-minor unit boundary. Confirm which convention your installed version expects, and that you are passing the server-issued amount_breakdown 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: this package’s money-field conventions changed at 0.7.0, and a unit-convention change is not something a test suite in your storefront will necessarily catch. 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.