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
{
  "handle": "acme-shipping",
  "name": "Acme Shipping",
  "scopes": [
    "read_orders",
    "write_orders",
    "read_metafields",
    "write_metafields"
  ],
  "extensions": {
    "liveRateProviders": [
      {
        "handle": "acme-rates",
        "endpoint": "${APP_URL}/api/quote",
        "timeoutMs": 5000,
        "description": "Live rates from Acme's carrier API."
      }
    ],
    "webhooks": [
      {
        "topic": "orders/create",
        "endpoint": "${APP_URL}/api/order-create"
      }
    ]
  }
}
FieldRequiredNotes
handleyesProvider handle within your app. Multiple providers per app allowed (e.g. domestic + international).
endpointyesURL the dispatcher POSTs to. Template ${APP_URL} resolves to your app’s base URL.
timeoutMsoptionalHard cap. Defaults to 5s, max 9s. Slow providers fail open — checkout never breaks.
descriptionoptionalShown in the merchant’s installed-apps admin.

2. Build the /quote endpoint

The dispatcher POSTs a Carrier-Service-shaped body, HMAC-signed with your app’s clientSecret over the raw bytes:
POST /api/quote HTTP/1.1
Content-Type:      application/json
X-LMS-Signature:   <hex(HMAC-SHA256(rawBody, clientSecret))>
X-LMS-Request-Id:  d3b8fa2c-...
Request body:
{
  "rate": {
    "origin": { "country": "IN", "postal_code": "110051" },
    "destination": {
      "country":     "IN",
      "country_code": "IN",
      "city":        "Mumbai",
      "province":    "MH",
      "postal_code": "400001",
      "zip":         "400001",
      "cod":         false
    },
    "items": [
      {
        "variant_id": "var_...",
        "product_id": "prod_...",
        "sku":        "ABC-MED",
        "quantity":   1,
        "grams":      1500,
        "price":      499,
        "title":      "Slipper M"
      }
    ],
    "currency": "INR",
    "locale":   "en"
  },
  "shop": {
    "domainSlug": "raja337276",
    "name":       "Acme Store",
    "url":        "raja337276.launchmystore.io"
  },
  "request_id": "d3b8fa2c-..."
}
Verify the signature, look up the merchant’s per-shop credentials from your data store (typically keyed on shop.domainSlug), then call your carrier and return: Response body:
{
  "rates": [
    {
      "service_name": "Blue Dart Air",
      "service_code": "acme-1",
      "total_price":  "8085",
      "currency":     "INR",
      "description":  "1 days · Blue Dart Air",
      "min_delivery_date": "2026-06-04T00:00:00Z",
      "max_delivery_date": "2026-06-05T00:00:00Z"
    },
    {
      "service_name": "DTDC Surface",
      "service_code": "acme-12",
      "total_price":  "4250",
      "currency":     "INR",
      "description":  "3 days · DTDC Surface"
    }
  ]
}
Each rate field:
FieldRequiredPurpose
service_nameyesBold title in the picker (e.g. "Blue Dart Air"). Becomes the rate’s display name.
service_codeyesStable per-courier identifier within your app. Echoed back to you on orders/create so you can map back to your carrier’s courier id. Convention: <app_handle>-<carrier_id> (e.g. acme-1).
total_priceyesString. Major currency units (rupees, dollars).
currencyyesISO-4217. Should match the merchant’s base currency unless your app handles FX.
descriptionoptionalSecondary line in the picker ("3-5 days · DDP available").
min_delivery_date / max_delivery_dateoptionalISO-8601. Shown to customers on themes that support delivery-window display.
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:
import express from 'express';
import { verifyRateRequest } from '@launchmystore/apps-shared';

app.post('/api/quote', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyRateRequest({
    rawBody: req.body.toString('utf8'),
    header:  req.headers['x-lms-signature'],
    clientSecret: process.env.LMS_CLIENT_SECRET,
  });
  if (!ok) return res.status(401).json({ error: 'Invalid signature' });

  const body = JSON.parse(req.body.toString('utf8'));
  const dest = body.rate?.destination || {};
  const items = body.rate?.items || [];
  // ... call your carrier, return rates
  res.json({ rates: [...] });
});
Or use the all-in-one middleware:
import { carrierServiceMiddleware } from '@launchmystore/apps-shared';

app.post(
  '/api/quote',
  carrierServiceMiddleware({ clientSecret: process.env.LMS_CLIENT_SECRET }),
  async (req, res) => {
    // req.body is already parsed and HMAC-verified.
    res.json({ rates: await myQuoteLogic(req.body) });
  },
);

4. Render-side guarantees

Each rate you return is tagged by the platform before it reaches the storefront with:
{ ...yourRate,
  appId:      "<your app uuid>",
  appHandle:  "acme-shipping",
  appName:    "Acme Shipping",
  source:     "live_rate_provider"
}
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:
○  Blue Dart Air   1 days · via Acme Shipping     ₹8,085.00
○  DTDC Surface    3 days · via Acme Shipping     ₹4,250.00
○  <merchant's own ShippingZone>                  ₹100.00
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:
{
  "order": {
    "orderId":   "abc-123",
    "invoiceId": "A0XJ12K",
    "status":    "pending",
    "email":     "buyer@example.com",
    "name":      "Live Buyer",
    "address":   "1 MG Road",
    "city":      "New Delhi",
    "state":     "DL",
    "country":   "India",
    "pinCode":   "110011",
    "mobileNumber": "9876543210",
    "finalPrice": 8160,
    "currency":   "INR",
    ...
  },
  "orderProducts": [
    { "name": "Slipper M", "quantity": 1, "price": 75, "varientId": "..." }
  ],
  "shop": {
    "storeId":    "<store uuid>",
    "domainSlug": "raja337276"
  },
  "shipping_lines": [
    {
      "title":    "Blue Dart Air",
      "code":     "acme-1",
      "source":   "acme-shipping",
      "price":    "8085",
      "currency": "INR"
    }
  ]
}
The shipping_lines[] array is the routing key. Your handler must inspect shipping_lines[].source and only act when it matches your app handle:
import {
  createFulfillment,
} from '@launchmystore/apps-shared';

app.post('/api/order-create', async (req, res) => {
  const { order, orderProducts, shop, shipping_lines } = req.body;
  const APP_HANDLE = 'acme-shipping';

  const myLine = (shipping_lines || []).find((l) => l.source === APP_HANDLE);
  if (!myLine) {
    // Customer picked someone else's rate (or a merchant zone). Skip.
    return res.json({ ok: true, skipped: 'not-my-rate' });
  }

  // Strip the app prefix to recover your carrier's courier id.
  const myCarrierId = Number(String(myLine.code).replace(/^acme-/, ''));

  // Look up the merchant's per-shop credentials.
  const creds = await loadCreds(shop.domainSlug);

  // Translate to your carrier's API.
  const { awb, courier_name } = await callMyCarrierCreateShipment({
    creds,
    order,
    orderProducts,
    courier_id: myCarrierId,
  });

  // Write back the AWB so the platform's Order shows tracking
  // and Order.status flips to 'shipped'.
  await createFulfillment({
    accessToken: await getOAuthTokenFor(shop.domainSlug),
    orderId: order.orderId,
    trackingNumber:  awb,
    trackingCompany: courier_name,
    trackingUrl:     `https://acme.example/track/${awb}`,
    status:          'success',
  });

  res.json({ ok: true, awb });
});
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 order also carries shippingMethod.source === "local_pickup" and pickupLocationId / pickupLocationName if you need to detect it explicitly (e.g. to suppress a “shipping” email).

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. Cache, retries, idempotency

ConcernPlatform behaviourYour app’s responsibility
Quote cache10 minutes, keyed on storeId + destination + items fingerprintNone
Quote retry3 retries on 5xx / network errors with 250/500/1000 ms backoffBe idempotent (same request → same rates)
Webhook delivery3 retries with exponential backoff: 1m / 5m / 15mReturn 2xx within 10s, or the dispatcher retries
Webhook signatureX-LMS-Signature: hex(HMAC-SHA256(rawBody, clientSecret))Verify before processing
AWB duplicatePlatform de-duplicates by tracking_number per orderDon’t worry about double-push from retries

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