SellAbroad Docs
Set up your store

Custom store

Connect your own storefront over our API. Embed our card field or hand buyers to our checkout, then receive signed webhooks.

No Shopify or WooCommerce? Connect your storefront directly to the SellAbroad API. You send us the cart, we run the international checkout and take the payment, and we notify your server with signed webhooks.

Before you start

Every request you make to us is authenticated with two headers, provided by SellAbroad when your account is set up:

HeaderValue
x-api-keyYour API key. Keep it server-side, never in browser code.
x-merchant-idYour Merchant ID.

Two ways to run the checkout

There are two ways to take payment. They differ in who creates the cart — pick one, don't combine them.

ApproachWho creates the cartYou hostSnippet
Hosted checkout (recommended)Your server calls POST /carts/from-api, then sends the buyer to our checkout page with the returned cart id.Shipping & coupon callbacks, and a webhook receiver.The checkout iframe.
Embedded card fieldThe widget calls POST /carts/from-api for you, from a data-from-api-payload attribute.A webhook receiver. You supply the totals, so you also calculate tax.The Payment Container widget.
  • Hosted checkout — you create the cart. We run the whole checkout inside an iframe on your page: the buyer enters their address, we fetch your shipping rates and validate coupons through callbacks you host, we calculate tax, the buyer pays, and we show the confirmation screen. See Hand off to hosted checkout.
  • Embedded card field — the widget creates the cart. You mount our Payment Container widget on your own checkout page and pass the cart in a data-from-api-payload attribute; the widget calls POST /carts/from-api itself. Do not also create the cart on your server, or you get a duplicate cart. Because you supply the totals here, you also calculate the tax (see Tax). See Embed our card field.

Create the cart

Create a cart with the buyer's items and shipping address. In hosted checkout your server sends this request; with the embedded card field the widget sends it for you (same fields, from data-from-api-payload). Amounts are in minor units (see below). The response returns medusa_cart.id — the cart id the buyer checks out against.

POST https://oms.sellabroad.com/carts/from-api
x-api-key: <your key>
x-merchant-id: <your merchant id>

{
  "external_cart_id": "cart-abc-123",
  "currency": "AED",
  "customer_country_code": "AE",
  "email": "shopper@example.com",
  "items": [
    {
      "external_product_id": "prod-001",
      "sku": "SKU-001",
      "title": "Premium Espresso Beans",
      "quantity": 1,
      "unit_price": 15000,
      "weight_grams": 250,
      "requires_shipping": true
    }
  ],
  "shipping_address": {
    "first_name": "Jane", "last_name": "Doe",
    "address_1": "1 Sheikh Zayed Rd", "city": "Dubai",
    "country_code": "AE", "phone": "+971501234567"
  }
}

The response confirms the cart and returns its id:

{
  "success": true,
  "medusa_cart": { "id": "cart_01J8XYZ..." },
  "source_platform": "custom_api",
  "external_cart_id": "cart-abc-123"
}

Every line item requires external_product_id (your own product id, used for reconciliation), sku, title, quantity, and unit_price. requires_shipping defaults to true, and weight_grams is required whenever an item requires shipping — leave it off a physical item and the whole cart is rejected with a 400.

Fields that are optional in the schema but required for card

email, shipping_address, and billing_address are all optional on the cart schema — but a card charge is rejected ("Billing details incomplete") unless the buyer's first name, email, street, city, and country are present by the time they pay:

Needed for cardCart field
First nameshipping_address.first_name (or billing_address.first_name)
Emailtop-level email
Streetshipping_address.address_1
Cityshipping_address.city
Countryshipping_address.country_code

Last name, second address line, state/province, postal code, and phone are genuinely optional. City or postal code alone is not enough. In hosted checkout the buyer can complete these on our page; with the embedded card field you must supply them, since you own the buyer form.

Pre-applied discounts (optional)

To apply a promo before the buyer reaches checkout — an automatic sale or loyalty credit — include a discounts array on the cart. Each entry has a code, a type (percentage or fixed), and a discounted_amount in minor units. The discounted_amount is authoritative: we deduct exactly that, and don't re-derive it from type / value. Codes the buyer types at checkout are handled by the coupon validation callback in hosted checkout — not here.

Amounts and money format

Every amount you send (unit_price, shipping, discounts) is an integer in the currency's smallest unit. The multiplier depends only on the currency. There are two short exception lists below; every other currency uses × 100.

CurrencyMultiply the displayed price byExample
Any currency not in the two rows below× 100 (cents)$47.70 → 4770
KWD, BHD, OMR, JOD, TND× 1000 (fils)10.500 KWD → 10500
BIF, CLP, DJF, GNF, JPY, KMF, KRW, MGA, PYG, RWF, UGX, VND, VUV, XAF, XOF, XPF× 1 (no minor unit)¥1000 → 1000

Check: your price times the multiplier must be a whole number. If it is not, you used the wrong row. We convert to each payment processor's format for you, so you never need to know which one runs the charge.

Tax

How tax is handled depends on which checkout you run:

  • Hosted checkout: we calculate tax for the buyer's destination during checkout. You don't call anything.
  • Embedded card field: because you supply the totals, you calculate the tax. Call POST /tax/calculate with the buyer's ship-to address and cart before the widget renders, and pass the amount it returns as data-tax-cents. See the Tax endpoint for the exact request and response.

As Merchant of Record, SellAbroad recomputes and verifies tax after every payment. Charging what POST /tax/calculate returns keeps you clear — persistent under-collection raises alerts and can get your account locked. (Shipping and discounts we do not recompute.)

Hand off to hosted checkout

Create the cart on your server, then point an iframe at our checkout page with the returned cart id. The buyer enters their address, picks a shipping method, applies coupons, pays, and sees the confirmation screen — all inside the iframe. There is no merchant-hosted thank-you page in this flow.

<iframe
  id="saCheckoutIframe"
  src="https://app.sellabroad.com/checkout/v2?cartId=CART_ID&merchantId=YOUR_MERCHANT_ID&intendedCurrencyCode=AED&shopUrl=yourshop.com"
  style="width:100%; height:100dvh; border:0;"
  allow="payment; fullscreen"
  allowfullscreen
></iframe>
ParamValue
cartIdThe medusa_cart.id returned from POST /carts/from-api.
merchantIdYour Merchant ID — the same value you send in x-merchant-id.
intendedCurrencyCodeISO 4217 currency (e.g. AED). Must match the cart's currency.
shopUrlYour storefront domain, no protocol (e.g. yourshop.com). Used for theming.

The buyer completes the entire purchase — including the confirmation screen — inside the iframe, so there is nothing else to build on the front end.

Optional: reflect completion in your page

If you want your parent page to react when the order is placed (analytics, back-button behaviour), listen for our postMessage:

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://app.sellabroad.com') return;
  if (event.data?.type === 'UPDATE_PARENT_QUERY') {
    // event.data.params includes orderStatus: 'completed' once payment lands
  }
});

Authoritative order state always arrives through the order.created webhook — this is a UI nicety only.

Shipping & coupon callbacks (endpoints you host)

During the buyer's checkout session, our page asks your server three questions in real time. You expose three HTTPS endpoints and we call them with the live cart and address; your responses drive what the buyer sees. All three are required for hosted checkout, and they fire only in this flow. Register their URLs on your integration config — see Configure your URLs.

How we authenticate our calls to you

Every request we send carries:

HeaderValue
X-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body using your shared_secret.
X-TimestampUnix seconds. Reject requests older than 5 minutes.
X-Request-IdUUID v4. Dedupe on it — we retry timeouts.
Content-Typeapplication/json.

Verify the signature over the raw, unparsed body — re-serializing the JSON first produces a different hash:

raw_body = read_request_body_as_bytes()
received = request.header('X-Signature')            // e.g. "sha256=4f3c..."
expected = 'sha256=' + hmac_sha256(raw_body, shared_secret).hex()

if not constant_time_equals(received, expected):
    return 401
// raw_body is verified — parse JSON and process

1. Shipping rates

Called when the buyer enters or changes their shipping address. We cache your response for 5 minutes per (cart, address) so repeated edits don't hammer your server. Timeout: 1.5 seconds — a slow response blocks the buyer.

We send:

{
  "external_cart_id": "cart-abc-123",
  "currency": "AED",
  "cart_subtotal": 15000,
  "items": [
    {
      "external_product_id": "prod-001",
      "external_variant_id": "var-001",
      "sku": "SKU-001",
      "title": "Premium Espresso Beans",
      "quantity": 1,
      "unit_price": 15000,
      "weight_grams": 250,
      "requires_shipping": true
    }
  ],
  "shipping_address": {
    "country_code": "AE",
    "city": "Dubai",
    "province": "DXB",
    "postal_code": "00000"
  }
}

You return (HTTP 200):

{
  "shipping_options": [
    {
      "handle": "standard",
      "title": "Standard delivery (3-5 days)",
      "description": "Local courier",
      "amount": 1500,
      "estimated_days_min": 3,
      "estimated_days_max": 5
    },
    { "handle": "express", "title": "Express (next-day)", "amount": 3500 }
  ]
}
FieldRequiredNotes
handleyesStable id for the rate; echoed back when the buyer selects it.
titleyesShown on checkout.
descriptionnoOptional subline.
amountyesInteger minor units, cart currency.
estimated_days_min / _maxnoOptional ETA display.

Return { "shipping_options": [] } when nothing ships to this address — the buyer sees a "no shipping available" message.

2. Coupon validation

Called when the buyer enters a code. No caching — every entry is a fresh call. Timeout: 2 seconds.

We send:

{
  "external_cart_id": "cart-abc-123",
  "currency": "AED",
  "code": "SUMMER10",
  "cart_subtotal": 15000,
  "items": [
    { "external_product_id": "prod-001", "sku": "SKU-001", "title": "Premium Espresso Beans", "quantity": 1, "unit_price": 15000 }
  ],
  "shipping_country_code": "AE"
}

Valid code (HTTP 200):

{
  "valid": true,
  "code": "SUMMER10",
  "title": "Summer Sale 10% Off",
  "type": "percentage",
  "value": 10,
  "discounted_amount": 1500,
  "target_type": "line_item",
  "target_selection": "all",
  "allocation_method": "across"
}
FieldRequiredNotes
validyestrue on this branch.
codeyesCanonical code (you may return it uppercased).
titlenoDisplay name; falls back to code.
typeyes"percentage" or "fixed".
valueyesPercentage 0–100, or minor units for fixed. Display only.
discounted_amountyesThe discount we apply, in minor units. We do not re-derive it from type / value.
target_typeyes"line_item" (products / cart total) or "shipping_line" (e.g. free shipping).
target_selectionno (default "all")"all", or "entitled" for only the SKUs in target_items.
target_itemswhen entitledSKUs the discount applies to.
allocation_methodno (default "across")"across" or "each"; only meaningful for line_item.

Invalid code — also HTTP 200, not a 4xx:

{ "valid": false, "code": "SUMMER10", "message": "This code has expired", "reason": "expired" }

message is shown to the buyer verbatim, so keep it customer-facing. reason is optional (not_found / expired / usage_limit_reached / min_purchase_not_met / not_applicable_to_cart / country_restricted / other) and only logged on our side.

3. Coupon removal

Called when the buyer removes an applied code. We block on your response — the discount is only cleared once you return 2xx, which keeps your coupon system and our cart in lock-step. Timeout: 2 seconds; a non-2xx, timeout, or malformed body leaves the discount applied and shows the buyer a retry hint.

We send the same body shape as coupon validation (external_cart_id, currency, code, cart_subtotal, items). Respond with any 2xx — the body can be empty {} or echo { "code": "SUMMER10" } for your logs. Do whatever bookkeeping your coupon system needs here (untrack the binding, decrement counters, write an audit row).

Embed our card field

Mount the Payment Container widget on your own checkout page. Pass the cart in data-from-api-payload and the totals in the data-*-cents attributes; the widget calls POST /carts/from-api for you and runs the buyer through card, wallet, and buy-now-pay-later flows inline.

<script src="https://app.sellabroad.com/api/widget?variant=container" async></script>
<div
  data-sellabroad-payment-container
  data-platform="api"
  data-merchant-id="YOUR_MERCHANT_ID"
  data-currency="AED"
  data-subtotal-cents="15000"
  data-discount-cents="0"
  data-shipping-cents="1500"
  data-tax-cents="750"
  data-total-cents="17250"
  data-from-api-payload='{"external_cart_id":"cart-abc-123","items":[]}'
></div>
  • Use data-platform="api" exactly — any other value silently falls back to Shopify and fetches /cart.js.
  • The widget POSTs /carts/from-api itself — do not create the cart server-side too. (If both paths must run, send the same external_cart_id on each: we key cart creation on your merchant id plus external_cart_id and collapse them into one cart.)
  • Set data-tax-cents, data-shipping-cents, and data-discount-cents whenever they apply — they default to 0. Get the tax from POST /tax/calculate first.
  • Totals must reconcile: subtotal − discount + shipping + tax = total (±1 minor unit).
  • external_cart_id is required and must be a real, unique id.
  • Order creation happens in the payment.container.succeeded webhook — not on the thank-you page.

This is the same Payment Container widget used on Shopify and WooCommerce. For the full attribute list and its payment.container.* webhook payloads, see the Payment Container reference.

Configure your URLs

Your callback and webhook URLs live on your integration config. Set them in your SellAbroad dashboard → Settings → Integration, or via PUT /integration/config (your account manager can also set them for you). Your shared_secret — which signs both our calls to you and our webhooks — is provided by SellAbroad.

FieldUsed for
shipping_rates_urlHosted-checkout shipping callback.
coupon_validation_urlHosted-checkout coupon validation callback.
coupon_removal_urlHosted-checkout coupon removal callback.
webhook_urlWhere we POST post-payment webhooks (below).
success_redirect_urlWhere "Continue shopping" sends the buyer after payment (see below).

Return URL after payment

Set your Return URL in your SellAbroad dashboard → Settings → Integration (the success_redirect_url field). After a successful payment, our confirmation screen shows a Continue shopping button, and your Return URL is where it sends the buyer:

  • Set it to a URL on your store, and Continue shopping takes the buyer there.
  • Leave it blank, and the buyer stays on our confirmation page.

For a single cart, you can override it by sending merchantSuccessUrl on POST /carts/from-api; otherwise the dashboard Return URL applies.

Our API does not return a returnUrls object (successUrl / cancelUrl / pendingUrl / errorUrl) — don't build against one. The only success-redirect inputs are the dashboard Return URL (success_redirect_url) and merchantSuccessUrl on cart-create.

Receive webhooks

After payment, SellAbroad notifies your server of order events by POSTing to your webhook_url. This is the hosted-checkout / API flow; the embedded card field instead emits payment.container.* events — see the Payment Container webhooks.

Verify the signature. Each request carries X-Signature: sha256=<hex>, an HMAC-SHA256 of the raw body using your shared_secret — the same verification as the callbacks above. Also sent: X-Timestamp, X-Event-Id (stable across retries — dedupe on it), and X-Event-Name.

Every event has the envelope { event_id, event_name, created_at, merchant_id, data }:

{
  "event_id": "550e8400-e29b-41d4-a716-446655440000",
  "event_name": "order.created",
  "created_at": "2026-04-25T14:00:00Z",
  "merchant_id": "merch_abc",
  "data": { }
}
EventWhendata
order.createdPayment succeeded and the order is persisted. Create the order on your side and decrement stock. We email the buyer their confirmation.{ order_id, external_cart_id, amount_cents, currency, payment_method, items, shipping_address, billing_address, email }
order.refundedA refund was processed, full or partial.{ order_id, external_cart_id, refund_amount_cents, currency, reason, refund_id }
order.cancelledThe order was cancelled before fulfillment.{ order_id, external_cart_id, reason }
order.chargeback_openedA card dispute was opened.{ order_id, dispute_id, amount_cents, reason, evidence_due_by }
order.chargeback_resolvedA dispute closed, won or lost (a loss is debited from your next payout).{ order_id, dispute_id, outcome }outcome is "won" or "lost"

Delivery. We allow 5 seconds per attempt and retry up to 8 times with backoff (immediate, then +2s, +10s, +30s, +2m, +10m, +1h, +6h). The event_id is stable across retries, so dedupe on it and return 200 for anything you have already processed. After the 8th attempt the event is marked failed and we are alerted internally — contact your account manager for a manual replay.

Report fulfillment to unlock payout

An order stays ineligible for payout until you confirm it shipped. Call POST /orders/{orderId}/fulfillment with status: fulfilled when the goods leave your warehouse. Until then the funds are held.

Cancellations and refunds

Cancellations and refunds are managed from your SellAbroad dashboard by your admin users — they are not part of this API. When one happens, we notify your server with the matching webhook (order.cancelled, order.refunded) so your system can mirror the change.

Troubleshooting

Hitting a 400 on cart creation, a 401 on webhooks, "Billing details incomplete", or duplicate carts? See Troubleshooting for causes and fixes.

On this page