Guides → Setup

Custom API

Custom API and JavaScript drop-in setup

Take a card payment on any storefront: classify a product, quote duty and tax, collect the card in the browser, and create the charge from your own server with an idempotency key.

Written against
Node SDK 0.6.0 · checkout drop-in 0.6.0 · API v1
Who owns what
You own your storefront, cart, and order. Open Border owns duty and tax quotes, payment intents, entity routing, and the ledger.

Step 1

Before you start

This is the path for a storefront that is not on a supported platform. You write the money calls yourself, so read these two boundaries before the first line of code — everything else in this guide assumes them.

  • An Open Border merchant account whose KYB review has been approved.
  • A charge currency of USD, GBP, EUR, CAD, or AUD. The charge currency selects the Open Border legal entity that acquires the payment; the shipping destination independently drives duty and tax. Neither follows the other, and the billing address routes nothing at all.
  • A server you can keep a secret on, and HTTPS between the browser and it.
  • Node.js if you want the typed SDK. Every call is a plain HTTPS request, so any language works — the SDK is a convenience, not a requirement.

Step 2

Get your test keys

Take both keys from the dashboard in test mode. They are not interchangeable, and mixing up which one goes where is the single most consequential mistake in this integration.

The two keys
KeyWhere it livesWhat it can do
Secret key sk_test_…Your server environment only.Classifies products, quotes duty and tax, and creates payment intents, captures, cancels, and refunds. It moves money.
Publishable key pk_test_…Browser code. Public by design.Authenticates one read-only configuration fetch so the card element can render. It can never move money.

There are two environments, and the API host follows the key’s rail automatically: a test key resolves to https://api-demo.openborderpayments.com, a live key to https://api.openborderpayments.com. Test and live are strictly gated — a test key touches only test rails and a live key only live rails, and they never mix. Keep the pair matched: sk_test_ with pk_test_, sk_live_ with pk_live_.

Step 3

Install

The typed Node client, for your server
npm install @open-border/node
The checkout drop-in, for the browser — one script tag, no build step
<script src="https://unpkg.com/@open-border/js"></script>

Both packages are also published to GitHub Packages under the @openborder scope, which needs npm configured for that scope and a token with package read access. If you are not on Node, skip the SDK and call the endpoints directly — the request and response contract is in the API reference, and the OpenAPI document is served at /openapi.json.

Step 4

Configure

Construct the server client
const { OpenBorderClient } = require('@open-border/node');

const openBorder = new OpenBorderClient({
  apiKey: process.env.OPENBORDER_API_KEY,
});
Client options
FieldWhat it doesSet it to
apiKey Your secret key, sent as a bearer token on every request. Read it from the environment. Required. Never a literal in source.
baseUrl Overrides the API host. Trailing slashes are stripped. Omitted, the host is chosen from the key’s rail. Leave it unset in production so the rail decides. Set it only for local development against a locally running API.
fetch An injectable fetch implementation, for tests. Optional. Defaults to the runtime global.

Then mount the card element in the browser with the publishable key. The element collects the card, tokenises it directly with the payment processor, and hands you a token through onSuccess. It never charges and never holds a secret key.

Mount the checkout element
<div id="ob-checkout"></div>
<script src="https://unpkg.com/@open-border/js"></script>
<script>
  OpenBorder(window.OPENBORDER_PUBLISHABLE_KEY).mount('#ob-checkout', {
    currency: 'USD',
    amount: 4200, // integer minor units — labels the pay button; not the charge
    onSuccess: async ({ paymentMethodId }) => {
      // Send the token to YOUR backend, which creates the charge with the SECRET key.
      await fetch('/checkout/charge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ paymentMethodId }),
      });
    },
    onError: (message) => console.error(message),
  });
</script>
  • currency is required and resolves the acquiring entity. Changing it re-fetches configuration and re-initialises the card fields, so the buyer must re-enter the card.
  • amount is display only — it labels the pay button. The real charge is the one your backend creates. The exception is wallet payments: when Apple Pay or Google Pay renders, amount is the total the buyer authorises in the sheet, so it must be the quoted charged total.
  • onSuccess receives { paymentMethodId, entity }. For wallet payments it may return a promise, and its outcome closes the wallet sheet — so return your backend charge call from it.
  • billingDetails attaches buyer identity to the payment method for address verification. It does not route anything.
  • Branding — logo, colours, font, radius, button label — is configured in your Open Border dashboard and applied automatically.

Step 5

Connect webhooks

Register a webhook endpoint in the dashboard for each rail and subscribe it to the events you act on — the payment-intent lifecycle, refunds, and disputes. Endpoint creation is a signed-in dashboard action; an API key cannot create or rotate one, so a leaked key can never redirect your callbacks.

Then verify every delivery before you trust it. Get all four of these right or you have an endpoint anyone can post to:

  1. 1 Verify the signature over the exact raw body Compute HMAC-SHA256 over the raw request bytes using the endpoint’s whsec_ secret and compare it in constant time with the OpenBorder-Webhook-Signature header. Do not parse the JSON first — a re-serialised body will not match. Make sure no proxy or body-parser middleware rewrites the bytes before you see them.
  2. 2 Reject anything outside the replay window Check OpenBorder-Webhook-Timestamp against a five-minute window, and keep your server clock synchronised. Without this, a captured delivery can be replayed indefinitely.
  3. 3 Deduplicate on the delivery id Store OpenBorder-Webhook-Id and treat a repeat as already handled. Delivery is at-least-once by design, so duplicates are normal, not a fault.
  4. 4 Read server truth before changing your own state Treat the callback as a prompt, not as the truth. Call GET /v1/payment_intents/:id for the authoritative status, then apply only a forward transition. This is what makes an out-of-order delivery harmless.

Step 6

Run a test payment

The full sequence is four calls. The first two are free of money and safe to run repeatedly; the third happens in the browser; only the fourth moves money, and it is the only one that needs an idempotency key.

1. Classify the product to get an HS tariff code
const { classifications } = await openBorder.createClassification({
  destination_country: 'GB',
  products: [
    {
      sku: 'TSHIRT-M',
      title: 'Organic cotton t-shirt',
      description: 'Unisex crew-neck, 100% cotton',
    },
  ],
});
// classifications[0].hs_code -> '610910'

Classify once and persist the code against your product. It does not change per order, and re-classifying on every checkout is wasted latency.

2. Quote duty and tax for the ship-to destination
const quote = await openBorder.createTaxQuote({
  destination_country: 'GB',
  destination_postal_code: 'SW1A 1AA',
  currency: 'GBP',
  shipping_amount: 500,
  line_items: [
    {
      sku: 'TSHIRT-M',
      description: 'Organic cotton t-shirt',
      quantity: 2,
      unit_amount: 2100,
      hs_code: '610910',
    },
  ],
  customer: { email: 'buyer@example.com' },
});
// quote.amount_breakdown -> { subtotal, shipping, tax, duty, total, currency }

quote.amount_breakdown.total is the landed cost to show the buyer. quote.expires_at is real — an expired quote is rejected at checkout, so quote close to payment rather than at the top of the funnel. The quote id is optional on the charge: pass it to charge duty and tax, omit it and the charge is merchandise plus shipping only.

Step three is the browser element from the Configure section, which returns a payment method token. Then create the charge on your server:

3. Create the payment intent — money-mutating, so it carries an idempotency key
const { randomUUID } = require('node:crypto');

const paymentIntent = await openBorder.createPaymentIntent(
  {
    tax_quote_id: quote.id, // omit to charge subtotal + shipping only
    amount: 4200, // merchandise SUBTOTAL in minor units, not the total
    currency: 'GBP',
    shipping_amount: 500,
    capture_method: 'automatic',
    payment_method: 'pm_...', // the token from the browser element
    customer: { email: 'buyer@example.com', name: 'Ada Lovelace' },
    billing_address: { line1: '1 High St', city: 'London', postal_code: 'SW1A 1AA', country: 'GB' },
    shipping_address: { line1: '1 High St', city: 'London', postal_code: 'SW1A 1AA', country: 'GB' },
    line_items: [
      {
        sku: 'TSHIRT-M',
        description: 'Organic cotton t-shirt',
        quantity: 2,
        unit_amount: 2100,
        hs_code: '610910',
      },
    ],
    merchant_reference: 'order_1042',
  },
  { idempotencyKey: randomUUID() },
);
// paymentIntent -> { id, status, entity, amount_breakdown, client_secret }
4. Read the authoritative status
const status = await openBorder.getPaymentIntent(paymentIntent.id);
// status -> { id, status, entity, amount, amount_captured, currency }

Run the three outcomes before you consider this working: card 4242 4242 4242 4242 succeeds, 4000 0027 6000 3184 raises an authentication challenge, and 4000 0000 0000 0002 declines. Use any future expiry and any CVC.

Every non-2xx response throws with a stable code — provider errors are never leaked through. Handle at least these, and decide what each means for the buyer’s order:

Errors you must handle
CodeWhat it meansWhat to do
validation_errorThe request did not satisfy the contract — a bad currency, a non-integer amount, a missing required field.Fix the request. Never retry it unchanged.
unauthorizedThe key is missing, wrong, or being used against the other rail.Check the key and that its rail matches the host you are calling.
currency_mismatchAmounts of two different currencies were combined.Fix the request. One charge is one currency.
country_not_supportedThe destination is not serviceable.Tell the buyer before you take a card, not after.
idempotency_key_conflictThe key was reused with a different request body.A bug in your key derivation. Do not retry — find out which operation the key already belongs to.
idempotency_in_progressThe first request with that key is still running.Wait and retry the same key. Do not start a new operation.
not_foundThe referenced payment intent or quote does not exist for this merchant.Check the id and the rail it was created on.
provider_errorThe payment processor failed.Safe to retry with the same idempotency key. Show the buyer a generic failure, never the underlying message.
network_error, request_timeout, request_abortedNo response arrived, so the outcome is unknown — the charge may have succeeded.Retry with the same idempotency key, or read the intent’s status. Never build a new request.

Step 7

Go live

  1. 1 Swap both keys together Move the server to sk_live_ and the browser to pk_live_ in the same change. Drop any baseUrl override and let the live key resolve the production host.
  2. 2 Audit what reaches the browser Search your built bundles, your server-rendered HTML, your logs, and your error-reporting payloads for sk_. This is the one mistake you cannot walk back — a leaked live secret key must be rotated immediately.
  3. 3 Register the live webhook endpoint It is a separate endpoint with its own whsec_ secret. Verify a real delivery against it before you rely on it, including a deliberate replay to confirm your deduplication works.
  4. 4 Confirm every money call persists its idempotency key before the call Deriving a key at call time, or generating one on retry, only shows up as a double charge under real load.
  5. 5 Confirm your CSP allows the processor SDK on production Staging and production CSPs differ more often than anyone expects, and the failure mode is a silently missing card field rather than an error.
  6. 6 Take one small real payment and refund it Check the intent, the amount breakdown, the resolved entity, and your own order record all agree before opening checkout to buyers.

Step 8

Troubleshooting

Symptoms and what to check
SymptomWhat to check
The card element does not renderA CSP or ad blocker is blocking the processor’s browser SDK, or the publishable key is on the wrong rail. Check the browser console first.
The pay button shows the wrong totalamount on the element is display-only. Pass the quoted landed-cost total if you want it to match what you charge — and you must, if wallet payments are enabled.
The quote is rejected at checkoutIt expired, or something changed after it was issued. Quote close to payment and do not mutate the cart afterwards.
The charge total is not what you expectedamount is the merchandise subtotal, not the total. Shipping is its own field, and duty and tax come from the quote.
A retry created a second chargeThe retry used a fresh idempotency key. Persist the key with the operation before the first call and reuse it.
Webhook signatures never verifySomething is re-serialising the body before you verify it. Verify over the raw bytes, and check that no proxy or body-parser middleware sits in front.
Duty and tax are zeroYou omitted tax_quote_id, which charges merchandise plus shipping only. That is the documented behaviour, not a fault.

Step 9

Upgrade and remove

The API is versioned under /v1 and the published contract is the OpenAPI document at /openapi.json. Pin the SDK version you tested against, read its changelog before bumping, and diff the OpenAPI document if you generate your own client from it.

To stop taking payments, remove the checkout element and stop calling the payment endpoints — but resolve every non-terminal payment first. Open Border stays the authoritative record for anything already charged, so an outstanding authorisation must be captured or cancelled explicitly rather than abandoned. Delete the webhook endpoints last, once nothing depends on their deliveries, and rotate the secret keys when you are done.