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:
| Header | Value |
|---|---|
x-api-key | Your API key. Keep it server-side, never in browser code. |
x-merchant-id | Your 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.
| Approach | Who creates the cart | You host | Snippet |
|---|---|---|---|
| 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 field | The 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-payloadattribute; the widget callsPOST /carts/from-apiitself. 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 card | Cart field |
|---|---|
| First name | shipping_address.first_name (or billing_address.first_name) |
top-level email | |
| Street | shipping_address.address_1 |
| City | shipping_address.city |
| Country | shipping_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.
| Currency | Multiply the displayed price by | Example |
|---|---|---|
| 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/calculatewith the buyer's ship-to address and cart before the widget renders, and pass the amount it returns asdata-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>| Param | Value |
|---|---|
cartId | The medusa_cart.id returned from POST /carts/from-api. |
merchantId | Your Merchant ID — the same value you send in x-merchant-id. |
intendedCurrencyCode | ISO 4217 currency (e.g. AED). Must match the cart's currency. |
shopUrl | Your 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:
| Header | Value |
|---|---|
X-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your shared_secret. |
X-Timestamp | Unix seconds. Reject requests older than 5 minutes. |
X-Request-Id | UUID v4. Dedupe on it — we retry timeouts. |
Content-Type | application/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 process1. 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 }
]
}| Field | Required | Notes |
|---|---|---|
handle | yes | Stable id for the rate; echoed back when the buyer selects it. |
title | yes | Shown on checkout. |
description | no | Optional subline. |
amount | yes | Integer minor units, cart currency. |
estimated_days_min / _max | no | Optional 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"
}| Field | Required | Notes |
|---|---|---|
valid | yes | true on this branch. |
code | yes | Canonical code (you may return it uppercased). |
title | no | Display name; falls back to code. |
type | yes | "percentage" or "fixed". |
value | yes | Percentage 0–100, or minor units for fixed. Display only. |
discounted_amount | yes | The discount we apply, in minor units. We do not re-derive it from type / value. |
target_type | yes | "line_item" (products / cart total) or "shipping_line" (e.g. free shipping). |
target_selection | no (default "all") | "all", or "entitled" for only the SKUs in target_items. |
target_items | when entitled | SKUs the discount applies to. |
allocation_method | no (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-apiitself — do not create the cart server-side too. (If both paths must run, send the sameexternal_cart_idon each: we key cart creation on your merchant id plusexternal_cart_idand collapse them into one cart.) - Set
data-tax-cents,data-shipping-cents, anddata-discount-centswhenever they apply — they default to0. Get the tax fromPOST /tax/calculatefirst. - Totals must reconcile:
subtotal − discount + shipping + tax = total(±1 minor unit). external_cart_idis required and must be a real, unique id.- Order creation happens in the
payment.container.succeededwebhook — 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.
| Field | Used for |
|---|---|
shipping_rates_url | Hosted-checkout shipping callback. |
coupon_validation_url | Hosted-checkout coupon validation callback. |
coupon_removal_url | Hosted-checkout coupon removal callback. |
webhook_url | Where we POST post-payment webhooks (below). |
success_redirect_url | Where "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": { }
}| Event | When | data |
|---|---|---|
order.created | Payment 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.refunded | A refund was processed, full or partial. | { order_id, external_cart_id, refund_amount_cents, currency, reason, refund_id } |
order.cancelled | The order was cancelled before fulfillment. | { order_id, external_cart_id, reason } |
order.chargeback_opened | A card dispute was opened. | { order_id, dispute_id, amount_cents, reason, evidence_due_by } |
order.chargeback_resolved | A 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.