Skip to main content

Live Rate Providers

A live rate provider is an HTTP endpoint your app exposes that the platform calls at checkout to fetch live shipping rates from your carrier. Rates you return render alongside the merchant’s own shipping zones — the customer picks one with a normal radio button, and your app’s chosen carrier identity follows the order through placement, webhook, and fulfillment write-back. Apps register a callback URL in their manifest, return rates on demand, and receive orders/create webhooks where they recognise their own picked rates and act on them. Common implementations include domestic and cross-border courier integrations.
For rate logic that doesn’t need a remote carrier API — e.g. a fixed surcharge above a cart-value threshold — use a shipping_rate Function instead. Functions run server-side in our sandbox; live rate providers run on your infrastructure.

The Flow

1. Declare in your app manifest

Add a liveRateProviders[] entry to your app.json. The endpoint template ${APP_URL} is substituted with the value the merchant configured at install time.
app.json

2. Build the /quote endpoint

The dispatcher POSTs a Carrier-Service-shaped body, HMAC-signed with your app’s clientSecret over the raw bytes:
Request body:
rate.origin is currently always {} — quote from the warehouse address the merchant configured in your app’s settings (as the reference apps do), not from the request.
Verify the signature, look up the merchant’s per-shop credentials from your data store, then call your carrier and return rates.
Key your per-merchant storage on shop.storeId — it is the immutable merchant identifier. shop.domainSlug changes when a merchant moves to a custom domain, and apps keyed on it lose every merchant that upgrades their domain. (Session tokens carry the same id as storeId/sub.)
Response body (total_price is in minor units"8085" is ₹80.85):
Each rate field:

Currency conversion

The dispatch body asks you to quote in rate.currency (the store’s display currency), but a carrier can only price in the currency it operates in. So the platform normalises every returned rate into the display currency before it reaches checkout, order validation or the persisted order:
  • currency equals the requested currency → used as-is, no conversion.
  • currency differs → the amount is converted at the platform’s exchange rate. The original is preserved on the rate as quoted_price / quoted_currency for reconciliation.
  • No usable exchange rate for that pair → the rate is dropped rather than charged at the wrong number, and a warning is logged.
Because conversion happens once, at the point rates enter the platform, everything downstream sees a single consistent currency and rate fingerprints still match at order-time re-validation.
Never label an amount with the requested currency when it is really in your carrier’s. Doing so was previously undetectable: a ₹104.85 rate returned as currency: "INR" to a LAK-display store was charged as ₭104.85 — off by a factor of ~20,000 — because the platform trusted the label. It now converts on currency, so an honest currency is required for a correct charge.
Fail-open semantics. Any of: timeout, 5xx, malformed JSON, empty array — treated as “no rates from this provider” and the checkout falls back to the merchant’s ShippingZones. Your provider failing is never a customer-facing error.

3. Verify the HMAC

Use the platform’s shared package:
Or use the all-in-one middleware:

4. Render-side guarantees

Each rate you return is tagged by the platform before it reaches the storefront with:
The /checkout picker renders every app’s rate with the same template, so your service_name and description appear exactly as you sent them — alongside a small via {Apps.name} label for trust signalling:
The customer picks one. No code change needed in your app for the display layer — your rate JSON is what the customer sees.

5. Receive the orders/create webhook

Subscribe to orders/create in your manifest (see step 1). When the customer places an order, the platform POSTs:
The body is the order in standard commerce shape — there is no { order, orderProducts, shop } wrapper. The sending store is the X-LMS-Shop-Domain request header, and shipping_lines[] (carrying your routing source) is preserved at the top level.
The shipping_lines[] array is the routing key. Your handler must inspect shipping_lines[].source and only act when it matches your app handle:
Why source-based routing matters. If a merchant installs BOTH your app and a competitor app, both apps receive the same orders/create webhook. Without the source check, both apps would race to push shipments. Inspecting shipping_lines[].source === APP_HANDLE is the contract that makes installed-apps coexist.
Native local pickup is handled by the same source check. When a merchant enables in-store pickup on a warehouse (no app required), and the customer chooses the Pickup tab at checkout, the order’s shipping_lines[].source is "local_pickup" — never your app handle. So the find((l) => l.source === APP_HANDLE) guard above already skips these orders correctly; you don’t need any extra branch (the pickup line’s source is "local_pickup", never your app handle).

6. Optional auto-push without customer pick

If the merchant has auto_push_enabled set in your config metafield AND shipping_lines[].source is null (= the customer picked a merchant zone, not your rate), you can still push if your app handles ALL of the merchant’s shipping. This is what Flow B looks like. The shipped Shiprocket app in the repo demonstrates both modes — see shiprocket/src/server.js /api/order-create for the source-match + auto-push patterns.

7. Storing merchant credentials

Apps often store per-merchant carrier credentials in shop metafields to stay database-free. Shop metafields are not private: themes can render {{ shop.metafields.your_namespace.api_password }} and other installed apps with read_metafields can list them. Never store credentials in plaintext — seal them first:
sealSecret is AES-256-GCM keyed off your clientSecret (enc:v1:... wire format); openSecret passes legacy plaintext through unchanged and returns null on tamper.

7. Cache, retries, idempotency

8. Multi-package support

You can split a single order across multiple shipments. Each call to createFulfillment with a different line_items subset becomes a new Fulfillment row + tracking entry on the order. Order.status flips to partial until coverage hits 100%, then shipped. See Create Fulfillment for the exact body shape and multi-package examples.

Reference