MARG Ship API

Your own systems talking to your MARG console: orders in, allocation and booking, tracking out, and webhooks the moment a parcel moves. Every call is the same call the console makes.

Authentication

An admin mints a key under Security → API keys. It is shown once. Send it as a bearer token on every request; the base URL is your console's origin.

curl https://margship.com/orders?tab=unshipped \
  -H "Authorization: Bearer mk_live_…"

A key is a role, never an admin: operations reads and writes orders and bookings; viewer reads. Data exports (CSV) need the export power switched on for the key. Keys are rate-limited to 600 requests a minute (429 past that) and can be revoked at any moment; every request a key makes is in your access log under api:<key name>.

Amounts are in paise unless a field says otherwise (the intake call takes rupees, marked below). Times are ISO-8601 in UTC. Pincodes are six digits.

Orders

POST/orders

Create an order. reference is your id and is unique per store — sending the same reference twice updates, never duplicates.

{
  "reference": "SO-10422",             // your order id, required
  "orderNumber": "#10422",             // optional, what the buyer sees
  "placedAt": "2026-09-18T09:12:00Z",  // optional
  "paymentMode": "cod",                // "cod" | "prepaid"
  "orderValue": 1299,                  // RUPEES
  "codAmount": 1299,                   // RUPEES, COD only
  "weightG": 450,
  "parcels": [ { "weightG": 2400, "lengthCm": 40, "widthCm": 30, "heightCm": 20 }, { "weightG": 1100 } ],   // optional: two or more boxes ship as one consignment
  "buyer": { "name": "Priya Menon", "phone": "+919811100078", "email": "priya@example.com" },
  "address": { "line1": "4 Marine Drive", "line2": "", "landmark": "", "city": "Kochi", "state": "KL", "pincode": "682001", "country": "IN" },
  "items": [ { "sku": "TEE-M-BLK", "title": "Cotton tee", "qty": 1, "unitPrice": 1299, "category": "Apparel" } ],
  "tags": ["vip"],
  "dangerousGoods": false
}

Answers 201 with the order (id, orderNumber, status). Validation failures are 400 with an issues list naming each field.

GET/orders?tab=unshipped&search=&from=YYYY-MM-DD&to=YYYY-MM-DD&limit=50&offset=0

Tabs: all, unshipped, shipped, delivered, problem, highrisk, needscall, onhold, duplicates, delayed. Add ids=a,b,c (up to 500 order ids) to fetch exactly those rows.

GET/orders/:id

One order with its address, items, risk, shipments and history.

POST/orders/:id/hold · /release · /cancel · /tags · /clone

Hold takes { "reason": "…" }; tags take { "add": [], "remove": [] }. Bulk: POST /orders/hold and /orders/release with { "ids": [...] }.

PUT/orders/:id/parcels
{ "parcels": [ { "weightG": 2400, "lengthCm": 40, "widthCm": 30, "heightCm": 20 }, { "weightG": 1100 } ] }   // 1–10 boxes; dimensions optional, all three or none; null = one box

How the order is packed. Two or more boxes ship as one consignment: one AWB and one label per box ("parcel 2 of 3"), each billed on its own weight, COD collected once on the first box, one Shopify fulfilment carrying every tracking number, and one cancel that undoes all of them. Refused with 409 once the order has shipped.

Allocate & ship

POST/orders/:id/allocate

Dry: which courier MARG would book and why — every candidate with its cost, days, eligibility, this week's health and the reasons. Nothing is booked.

POST/orders/:id/ship
{ "live": true, "carrierCode": "delhivery" }   // both optional: live defaults to the store's setting, carrierCode overrides allocation

Books the parcel: answers with awb, carrierCode, chargeableWeightG, estimatedCostPaise and an explain sentence. A split order (see /orders/:id/parcels) also answers pieces: [{ awb, pieceNo, shipmentId, chargeableWeightG }], with awb the first box's and the weight and cost summed; a courier refusing any box cancels the boxes already booked and the order stays unshipped. A held order, an unverified COD order under your gate, or a courier that cannot take it answers 422 with the reason.

POST/ship/bulk
{ "orderIds": ["…"], "live": true }      // up to 200; one result per order
POST/orders/:id/cancel-shipment · /ship/cancel-many

Cancel with the courier and reopen the order. Bulk takes { "orderIds": [...], "note": "…" } and reports each order in its own words — a parcel already picked up is refused, not hidden in a count.

GET/shipments/:id/label

The label as a printable page. Bulk: GET /labels/selected.html?ids=… and /labels/selected.zip?ids=… (needs the bulk-labels power on a person; a key holds it).

Tracking

GET/shipments/:id

Status, the courier's scans in order, delivered/picked-up times, the promised and predicted dates, and podUrl when the courier supplied proof of delivery.

GET/track/:handle/lookup?q=<order or AWB>&phone=<last 4>

The public tracking answer your buyers see, no key needed.

Where a parcel has been

The scans you already receive, placed against India’s pincode centroids and projected into a fixed box — so you can draw the route without a mapping vendor. Stops MARG could not place are listed rather than dropped, and the final point is the delivery pincode’s centre, never the buyer’s own coordinates.

CallWhat it does
GET /shipments/{id}/journey{ points: [{ at, status, place, lat, lng, source, legKm, x, y }], unplaced, distanceKm, viewBox, note }

source says how firmly the stop is known, weakest last: scan (the courier’s own coordinates) · city (the city it named, matched to a district) · office (a town, matched to India Post’s office names — a name that belongs to more than one district is not placed at all rather than averaged) · facility (a word pulled out of the hub string) · origin and destination (your warehouse and the delivery pincode’s centre, which are not scans).

Rates

POST/rates/quote
{ "destPincode": "560001", "weightG": 750, "paymentMode": "cod", "codAmountPaise": 129900,
  "warehouseId": "…", "dims": { "lengthCm": 20, "widthCm": 15, "heightCm": 5 } }   // warehouseId and dims optional

The same allocator the ship button uses, before there is an order: every courier's price from your rate cards, days, zone, the delivery estimate and the pick.

GET/rates/card

Lists

POST/orders/resolve
{ "text": "#10422\n10423, 1234567890123" }   // order numbers, your references or AWBs, any mix, up to 500

Answers with the order id of each match and the tokens it could not find, as typed.

GET/dispatch/list.html?ids=…

A handover sheet for those parcels, grouped by courier.

Hyperlocal riders

A rider from the warehouse to the door within the hour (0116): Porter for Business as the first courier, attached like any other own-contract account (apiKey, optional baseUrl for their UAT). There is no label and no manifest. In POST /orders/:id/allocate a rider courier is judged by a live quote — both ends need coordinates (the warehouse card; Shopify's own geocode on the shipping address) and must sit in the same pincode area — and the candidate reads "rider available now, at the pickup in N min for ₹…", the fare as its cost, today as its TAT. The AWB is Porter's order id. On the tracking page, while the trip runs, the buyer sees the rider's name, vehicle, a call link, a map link and the ETA, and the parcel is "Arriving today" — a courier network's estimate days out is never shown for a rider. Built to Porter's published partner API and not yet run against a live key: the connection test is a real quote. Shadowfax Flash: not built — no public API document.

The support assistant

Answers "where is my order" and policy questions on WhatsApp, by email and on your own site (0117) — from the customer's own orders and your own words, never from anything else. Every date, amount, AWB and link it states must appear in the facts it was handed; every rule it states cites one of your clauses by id ([R1] the returns window, [E1] order changes, [S1] your support line, [D1…] the paragraphs of the policy document you paste in the console). A draft that fails those checks is not sent: the customer hears that the team will reply, and the thread is handed to a person with the reason on it. It also hands off when the customer asks for a person, reports a damaged or wrong item, or when the facts do not answer. Off by default, per channel, under Engage → Assistant. With no model key on the deployment it still answers, in plain English, from the same facts under the same checks.

Who is asking is the channel's identity, never a claim in the text: a WhatsApp number, or an email address the receiving side verified (SPF or DKIM passed). An unknown sender is asked for the order number and the phone used at checkout, and told nothing. The street is never a fact; city and pincode are, like the tracking page.

POST/support/ask
{ "phone": "+919811100120", "text": "where is my order?" }          // or "email": "…"; your site vouches for the identity it passes
→ { "conversationId": "…", "answer": "Your parcel with Delhivery, AWB … is on the way …", "intent": "wismo", "citations": [], "handoff": false, "status": "answered" }

The answer comes back in the response; the exchange is recorded as a conversation on channel api so the console shows it beside the others. Intents: wismo, delivery_date, failed_delivery, cancel, change_address, return, exchange, refund, damaged_or_wrong, cod, policy, greeting, thanks, human, other. 120 questions a minute per store; needs the assistant switched on for "your own site".

GET/support/policy · PUT/support/policy · GET/support/conversations?status=agent|human|closed · GET/support/conversations/:id · POST/support/conversations/:id/reply · POST/support/conversations/:id/status · POST/support/try · GET/support/stats

Email arrives through Amazon SES → SNS → POST /webhooks/email-inbound/:token: the SNS signature is checked against a certificate on an SNS host, the subscription is confirmed automatically, and the customer's fresh text is read from the MIME (quoted history and signatures cut). Your assistant address is <handle>@<inbound domain>; set it as your support address or forward your support mailbox there, and MARG's own buyer emails carry it as Reply-To once the email channel is on. The inbound domain, the SES receipt rule and the SNS subscription are set up by MARG's operator per deployment; until then the console says the channel is not available rather than inventing an address. Product questions (0121): with the catalogue switch on, a question about sizes, colours, price or stock searches your connected Shopify store by the question's own words, live, and the matching products — price, which variants are in stock, the product link — join the facts under the same guard; nothing is remembered between questions and a product the store does not have is handed to a person, never guessed. A connected WooCommerce store answers the same way when Shopify is not connected (0124): its published products by the question's words, a variable product's variations as the sizes and colours in stock. Unicommerce is a warehouse, not a shop — no catalogue there. Not built: voice on the assistant (calls are the IVR's, see Voice), and actions the assistant takes itself (it points to the tracking page and the returns page, where the customer can act with the two factors).

Stores that are not Shopify

Shopify connects through the MARG app. A WooCommerce store or a Unicommerce (Uniware) warehouse connects here with its own API keys — tested against the store before anything is stored, encrypted at rest, never returned. Every order then travels the same rails as a Shopify order, under its own channel (woocommerce, unicommerce).

GET/channels · POST/channels/test · POST/channels
{ "kind": "woocommerce", "url": "https://yourstore.in", "credentials": { "consumerKey": "ck_…", "consumerSecret": "cs_…" } }
{ "kind": "unicommerce", "url": "https://yourbrand.unicommerce.com", "credentials": { "username": "…", "password": "…", "facility": "GGN" } }

WooCommerce: MARG registers order.created and order.updated webhooks on the store (a Read/Write key is needed) and also polls every 20 seconds by modification time, so a lost webhook is never a lost order; when a parcel is booked, the order gets a customer-visible note with the courier, the AWB and the tracking link, and is marked completed. Unicommerce: a 20-second poll of sale orders updated since the last look, built to their public API document and not yet run against a live tenant — the connect step signs in and searches first, so a server that disagrees is refused with its own words. When a parcel is booked (0123), the courier and the AWB are written onto the sale order's shipping package — allocateShippingProvider for a package with no courier, edit for one that has a courier, never a dispatched or cancelled package — under your facility code, once per parcel, retried ten times on error; the provider code is the one your Uniware knows the courier by: PUT /channels/:id/settings { "providerCodes": { "delhivery": "DELHIVERY_SURFACE" } }, else the courier's code in capitals. Easyecom and other order systems: no verified adapter — push orders through POST /orders with your API key, or the order sheet.

POST/channels/:id/poll · POST/channels/:id/pause { "paused": true } · DELETE/channels/:id

Pay online instead of cash on delivery

GET/payments · PUT/payments/account · PUT/payments/policy · GET/orders/:id/payment-link · POST/orders/:id/payment-link
PUT /payments/account   { "keyId": "rzp_live_…", "keySecret": "…", "webhookSecret": "…" }
PUT /payments/policy    { "enabled": true, "discountPct": 10, "discountMaxPaise": 20000, "expiresHours": 48, "offerOnCall": true, "callSentence": "Press 4 to pay online now{discount} and skip cash on delivery." }
POST /orders/:id/payment-link  →  { "ok": true, "link": { "amountPaise", "discountPaise", "shortUrl", "status", "sent": { "sms", "whatsapp", "email" }, "expiresAt" } }

A Razorpay payment link on your own account (0127) for a cash-on-delivery order — the COD amount less a discount you choose, capped — sent to the buyer by SMS (your pay_link DLT template, with {amount} and {link}), WhatsApp (the template you name as payment_link) and email, once. Offered as key 4 on the COD confirmation call when offerOnCall is on, sent from an order's drawer, or here with your API key. One open link per order (409 while one is out); a prepaid or a shipped order is refused in words. When Razorpay says paid, the order becomes prepaid — the COD amount zero, the confirmation done — the buyer is told, and a Shopify order is marked paid where the store allows it. Register the webhook URL shown under Payments in Razorpay (Settings → Webhooks) for payment_link.paid, payment_link.expired and payment_link.cancelled, with the same secret you enter here. MARG never holds the money and never sees a card.

Pickup requests

GET/dispatch/pickups · POST/dispatch/pickups
{ "warehouseId": "…", "carrierCode": "delhivery", "date": "2026-09-23", "count": 12, "reason": "manual" }

Ask the courier to come (0126). GET answers what is booked and not yet collected per warehouse and courier — the count a request should carry — and the asks made so far. POST asks for the date (IST, today to a week ahead; count omitted means what is waiting, at least one) and answers the courier's own reference; a refusal comes back as 422 in the courier's words, and either way the ask is on record. Delhivery takes a pickup request for a registered location; Ekart and Xpressbees collect on each booking's own pickup date, so there is nothing to ask and the answer says so; Blue Dart booking is not enabled yet.

Your own courier contract

You ship on MARG's couriers at MARG's rates by default; a courier contract of your own is attached by MARG. Since 0114 you ask from the console or the API: type the credentials, let the courier accept them, send the request. MARG approves with one click or declines with a note; only approval writes the account. Your credentials are stored encrypted on the request and are never returned — not to you, not to MARG's staff. PUT /carrier-accounts/:code still refuses, on purpose.

GET/carrier-accounts

Every courier with whose account carries your parcels, the keys a request needs (requiredKeys, optionalKeys), whether a connection test exists (canTest), and your latest request per courier (request: status, the test's verdict, MARG's note).

POST/carrier-accounts/:code/test
{ "credentials": { "token": "…", "clientName": "…", "baseUrl": "https://…" } }   // answers verdict accepted | refused | inconclusive | untested — nothing stored
POST/carrier-accounts/:code/request · DELETE/carrier-accounts/:code/request
{ "credentials": { … }, "note": "our account manager, the rates we hold" }   // 409 while one is already waiting; DELETE withdraws it

Returns policy by customer segment

Inside PUT /branding, returns.segments (0113): off by default. When on, every buyer on the public returns page is read into a segment at the moment of the request — first order, VIP, regular, serial returner, risky — from your customer book (delivered orders, RTOs, spend, this store's return requests) and the MARG buyer network (parcels returned to sender across stores, the fraud flag), most cautious reading first. Each segment's rule can change the window, auto-approve always (ignores the value cap) or never, offer store credit only (no refund), require photos, or block the online return — the page then asks the buyer to contact you. The buyer is never told their segment. The request records segment and segment_facts; GET /returns/requests and GET /customers carry them.

"segments": { "enabled": true,
  "thresholds": { "vipMinOrders": 5, "vipMinSpendPaise": 0, "serialMinRequests": 3, "serialMinRatePct": 50, "riskyNetworkMinOrders": 3, "riskyRtoPct": 40 },
  "rules": { "vip":             { "windowDays": 21, "autoApprove": "always" },
             "serial_returner": { "windowDays": 5, "autoApprove": "never", "storeCreditOnly": true, "photos": "required" },
             "risky":           { "block": true } } }

Return pickups

GET/orders/:id/return-pickup/options · POST/orders/:id/return-pickup
{ "carrierCode": "ekart", "warehouseId": "…", "reason": "wrong size", "returnRequestId": "…", "live": true,
  "pickup": { "name": "…", "phone": "…", "line1": "…", "city": "…", "state": "…", "pincode": "…" },
  "qc": { "checks": ["brand_tag", "colour", "seal"],                       // doorstep QC (0112) — optional
          "facts": { "brand": "Divine Hindu", "colour": "Red" },            // corrections to what the order says
          "instruction": "Count the beads" } }

Doorstep QC: the checklist the courier's rider works through before taking the parcel back. The options read returns qc — your policy, the eleven checks, a preview built from the order's variant and vendor, and where each courier receives it: Delhivery on the rider's pickup slip, Ekart on the return label and the item attributes. Leave qc out to use your policy; send it to correct the facts or the ticks for one pickup, even when the policy is off. Neither courier publishes a per-shipment QC API field, so the words on the slip and the label are what the rider reads; account-level QC reverse pickup is a setting with the courier.

Buyers

POST/buyers/opt-out
{ "phone": "9811100109", "channel": "whatsapp" }   // or "email": "…"; channel: whatsapp | email | all (default all)

The buyer hears nothing more on that channel from this store — absolute until POST /buyers/opt-in with the same body. Answers with how many buyer records matched. Every buyer email MARG sends already carries a one-click unsubscribe.

PUT/branding { "notifications": { "email": { "language": "hi", "texts": { … } } } } · POST/branding/notifications/preview · POST/branding/notifications/test-email
{ "notifications": { "email": { "enabled": true, "language": "hi",
    "texts": { "hi": { "shipped": { "subject": "{brand}: ऑर्डर {orderNo} रवाना हो गया", "headline": "आपका ऑर्डर रवाना हो गया है",
                                    "body": "नमस्ते {name}, {courier} ने ऑर्डर {orderNo} उठा लिया है। ट्रैकिंग नंबर {awb}।" } },
               "en": { "shipped": { "headline": "On its way", "body": "{courier} has picked up order {orderNo}." } } } } } }

Buyer email in your own words, per language (0122). A text per two-letter language and per moment — shipped, in_transit, out_for_delivery, delivered, ndr, delayed, return_approved, return_rejected, return_completed, refund_recorded, exchange_booked — with a subject (blank: the headline and the order number), a headline and a body; the placeholders {brand} {name} {orderNo} {courier} {awb} {date} {reason} {amount} {method} {reference} {exchangeOrder} {kind} are filled when the mail goes, an unknown one is left blank, and your text is text — never HTML. The buyer hears the language they last wrote to your store in (through the assistant), else your store's language, else your English text, else MARG's English words; nothing is translated. The tracking link, your note line and the unsubscribe stay on every mail. preview takes { "event", "language", "texts"? } — unsaved texts included — and answers the rendered subject, text and HTML with sample facts and whose words they are (source: merchant | marg); test-email mails that rendering to the signed-in person (admins), never to a buyer.

Failed deliveries (NDR) and failed pickups (NPR)

Every failed scan is read into one of seventeen standard buckets — customer unavailable, rejected, rescheduled by customer, no attempt, unreachable, address issue, payment not ready, out of delivery area, already cancelled, self-collect, seized or damaged, wants open delivery, misrouted, OTP cancellation, OTP mismatch, appointment required, external delay — from the courier's own words first and the canonical reason second; a return pickup nobody collected reads into six pickup buckets. The bucket sets the sentence the buyer is asked with, whether asking makes sense at all, and what MARG suggests you do.

GET/ndr?status=open|actioned|all|npr&limit=50&offset=0

The queue. Each row carries bucket, bucketLabel, bucketNext, the courier's rawStatus/rawCode, and the buyer conversation with its journey: journeyStep (0 asked · 1 followed up by email · 2 handed to a person), channel (whatsapp | link), closeReason ("not asked: …" for a bucket that is not a question for the buyer). status=npr lists failed return pickups.

GET/ndr/automation · PUT/ndr/automation
{ …the ask settings…,
  "followUpMinutes": 60,        // no reply on WhatsApp: the same question by email, each choice a signed link
  "handOffMinutes": 120,        // still silent: marked "needs you"; the exception rule "undelivered, buyer silent" can raise it
  "followUpEmail": true,
  "skipBuckets": ["wants_open_delivery"] }   // buckets you never want asked; GET returns "buckets", the catalogue

The email's links open /track/:handle/ndr/:token — a public page in your brand with the same choices. A GET never files anything; the buyer's tap POSTs the answer, which goes through the same decision as a WhatsApp reply (your auto-file switch, the refusal guard, the budget) and is accepted once.

GET/ndr/report?days=30

The communication report: per bucket, failed parcels and attempts, asked and deliberately not asked, answered (on WhatsApp / from the email), filed with the courier, mailed, handed to a person, and how the parcels ended — delivered, returned, still out. Also as the scheduled-report kind ndr_journeys, one row per conversation.

Exception rules

Your own thresholds, watched every hour: a parcel handed over and not collected, one booked and never put on a sheet, one collected and then silent, a promise due tomorrow with the parcel not yet out for delivery, a return pickup nobody came for. Each rule opens one alert per parcel while it fires and clears it the sweep the parcel moves. You are told on WhatsApp, by email or on a Slack-compatible webhook — the moment a rule fires, or once a day as a digest — and every alert also reaches your registered webhooks as exception.opened / exception.resolved.

GET/exceptions/rules · PUT/exceptions/rules
{ "enabled": true,
  "rules": { "pickup_late":        { "on": true, "threshold": 24, "openTicket": true, "rebookAfterHours": 48 },
             "in_building":        { "threshold": 48 },
             "stuck":              { "threshold": 48, "openTicket": true },
             "promise_risk":       { "threshold": 1 },                       // days ahead; 0 = today only
             "return_pickup_late": { "threshold": 72, "openTicket": true },
             "ndr_unanswered":     { "threshold": 3, "openTicket": true } },    // asked about a failed delivery, silent this many hours
  "delivery": { "mode": "instant" },                                        // or "digest" with "digestHourIst": 9
  "channels": { "whatsapp": ["+919812345678"], "email": ["ops@yourstore.in"],
                "webhookUrl": "https://hooks.slack.com/services/…" } }     // MARG posts { "text": "…" }

Thresholds are hours except the promise rule (days). A rule that cannot open a ticket or rebook refuses in words rather than ignoring the flag; a rebook hour earlier than the alert hour is refused too. Cancel-and-rebook runs only for "handed over, not collected", checks that another courier can carry the order before anything is cancelled, refuses on a deployment that books in dry-run mode, and tries once per alert — the reason it did not act is on the alert.

GET/exceptions?status=open|resolved|all&rule=stuck&limit=200

The alerts with their rule, AWB, order number, courier, age, what was found, what was done (ticket:T-12, rebooked:AWB (xpressbees)) or why not, and the open count per rule.

POST/exceptions/:id/dismiss

Closes the alert and never raises that parcel for that rule again.

POST/exceptions/run
{ "dryRun": true }   // the default: says what would open and resolve, writes nothing, sends nothing
POST/exceptions/test

One test message to every configured channel, so you know the alerts reach you.

Webhooks

Register an endpoint under Webhooks in the console (or POST /webhooks with { "url", "events" }). MARG POSTs JSON with these headers:

HeaderMeaning
X-MARG-Eventthe event name
X-MARG-Deliverya unique id per delivery — retries reuse it
X-MARG-Event-Keyone key per change (the parcel, the event, the status, the courier's moment) — the same on every retry and on a replay of the same state; keep the keys you have seen and drop a duplicate however it arrived (0125)
X-MARG-Timestampunix seconds when it was sent
X-MARG-Signaturesha256=hex(HMAC-SHA256(secret, timestamp + "." + body))

Verify the signature with the endpoint's secret and refuse timestamps older than five minutes. Answer 2xx within ten seconds; anything else is retried with backoff.

EventWhen
shipment.bookedthe parcel has an AWB
shipment.pickup_scheduled · picked_up · in_transit · out_for_deliverythe courier's scans
shipment.delivered · undelivered · exceptiondelivered, an NDR, a problem
shipment.rto_initiated · rto_delivered · cancelledreturning, returned, cancelled
ticket.opened · ticket.closedescalations
dispute.opened · dispute.closedweight disputes
exception.opened · exception.resolvedyour exception rules — the rule, the parcel, what was found; on resolve, why (cleared, dismissed, rebooked, cancelled)

The body carries the order number, AWB, courier, status, the time the courier reported, and the destination city and pincode — never the buyer's name, phone or street; you already hold those against the order number. Since 0125 it also carries eventKey, the order's lines (order.items[]: sku, title, qty, unitPricePaise, variant, vendor), and for an order shipped as several boxes the piece this parcel is (shipment.piece {no, count}), every piece with its status (order.pieces[]) and order.delivery {pieces, delivered, state}partial when some boxes are delivered and some are not. Boxes are declared by weight, not by line, so which items sit in which box is not on the body.

How your endpoint wants to be called (0118). The signature is sent whatever you choose; the auth mode is for endpoints that insist on their own door. Set it on the endpoint as auth:

{ "mode": "none" }                                                        // the signature only
{ "mode": "header", "headerName": "X-API-Key", "value": "…" }             // a static value on a header of your choosing
{ "mode": "basic", "username": "…", "password": "…" }                     // HTTP Basic
{ "mode": "dynamic", "tokenUrl": "https://auth.yours.com/oauth/token",   // a short-lived token from your auth endpoint
  "method": "POST", "contentType": "json" | "form",
  "body": { "grant_type": "client_credentials", "client_id": "…", "client_secret": "…" },
  "tokenPath": "access_token", "expiresPath": "expires_in", "headerName": "Authorization", "prefix": "Bearer " }

A dynamic token is fetched once, cached until it expires, and fetched again when your endpoint answers 401 or 403 — that delivery is then sent once more with the new token. The token URL passes the same check a delivery URL does: public https, never a private address. Saved values are never read back; GET /webhooks shows the mode and the non-secret parts. The older authHeader field still works and means a static Authorization header. Replay: POST /webhooks/:id/replay with { "awbs": [...], "event"? } sends each parcel's current state as a fresh delivery (marked "replay": true in the body); POST /webhooks/deliveries/:id/retry re-sends one delivery; POST /webhooks/:id/retry-dead re-queues every dead one. All three are audited.

Voice and SMS (Exotel)

A call from your own Exotel number that reads one sentence and takes one key press (0119): to confirm a cash-on-delivery order a person would otherwise ring, and to ask what next when a delivery failed and the WhatsApp ask went unanswered — or could not be sent. The press is filed exactly as a WhatsApp button or an email link would be: "1" on a COD call is the same verdict as Verified in the console; "1" or "2" on a failed-delivery call goes through the same decision and the same guards as the NDR buttons, "3" hands the parcel to you. Built to Exotel's public Voice API as a key-press IVR; a conversational voice agent (an AI that talks) is a vendor choice for your store, and when one is chosen it takes the same questions and files through the same doors.

GET/voice · PUT/voice/account · DELETE/voice/account · PUT/voice/policy · POST/voice/test-call · POST/voice/run · GET/voice/calls
PUT /voice/account   { "accountSid": "…", "apiKey": "…", "apiToken": "…", "callerId": "0XXXXXXXXXX", "appId": "4321", "apiHost": "api.exotel.com" }
                     // read back from Exotel before it is stored; the key and token are encrypted and never returned
PUT /voice/policy    { "cod": { "enabled": true, "afterMinutes": 30, "attempts": 2, "retryAfterMinutes": 120, "prompt": "…{brand}…{orderNo}…{amount}…" },
                       "ndr": { "enabled": true, "afterMinutes": 90, "attempts": 2, "retryAfterMinutes": 120, "prompt": "…" } }
POST /voice/test-call { "phone": "+91…" }   // rings your own number now with the test sentence

Both questions are off by default and refuse to switch on without an account. Calls are held inside your NDR quiet hours, placed once per attempt, and never twice for one order or one failed delivery. The flow itself is built in Exotel's dashboard — a Gather applet that reads MARG's sentence from the say URL and collects one digit, a Passthru applet that sends the digit to the passthru URL (200 = understood, otherwise the flow loops), and the call's end posted to the status URL. The three URLs, with your own token in them, are on the NDR Centre card; every callback must also name the call MARG placed, or it is a 404.

SMS (0120). The same Exotel account texts the buyer: the failed-delivery ask as one link to the answer page (sent at the journey's follow-up step beside the email), and the parcel's moments — shipped, out for delivery, delivered, running late — once per parcel, on the same claim tables as WhatsApp and email. In India a transactional SMS goes out only under a DLT-registered header and template, so MARG sends nothing it was not given: PUT /voice/sms takes the header (smsSenderId), the entity id and, per moment, the template id with the exact registered text — {brand}, {orderNo}, {courier}, {awb} and {link} stand where the registration has variables. A moment without a template is not sent. POST /voice/test-sms sends a moment's template with sample values to your own number; Exotel's delivery reports land on the message.

PUT /voice/sms  { "smsSenderId": "MARGSH", "dltEntityId": "1101…", "policy": { "enabled": true,
                  "templates": { "shipped": { "dltTemplateId": "1107…", "text": "{brand}: order {orderNo} is on its way with {courier}, AWB {awb}. Track: {link}" },
                                 "ndr_ask": { "dltTemplateId": "1107…", "text": "{brand}: we could not deliver order {orderNo}. Tell us what to do: {link}" } } } }

Reports, the audit log, proof of delivery

Three answers a merchant's own systems ask for (0118), from data MARG already keeps.

GET/reports · GET/reports/rows/:kind?from=YYYY-MM-DD&to=YYYY-MM-DD&limit=100&offset=0

The exports as pages of JSON — orders, shipments, scans, ndr, ndr_journeys, notifications, tickets, weight_disputes — with the CSV's own column names, so the Download button and the API can never disagree about what a column means. Dates are IST days; amounts are rupees; next names the following page. These rows carry buyer details, so a key needs the export power, exactly like the CSV.

→ { "kind": "shipments", "columns": ["awb", "carrier", …], "rows": [{ "awb": "…", "carrier": "delhivery", … }], "offset": 0, "limit": 100, "hasMore": true, "next": { "offset": 100, "limit": 100 } }
GET/reports/daily?from=YYYY-MM-DD&to=YYYY-MM-DD

Per IST day: orders placed, parcels booked, collected, delivered, returned to sender, and parcels with a failed delivery attempt; per courier over the window: booked, delivered, RTO, undelivered now, average hours from pickup to delivery, and delivered on or before the courier's promise. Counts only, so every role may read it; up to 92 days at a time. The Analytics screen's own numbers are at GET /insights/analytics/v2?from=&to=&granularity=day|week|month.

GET/audit?from=&to=&actor=&entity=&method=&allowed=true|false&changes=1&cursor=&limit=100

Every request a person or a key made to your store — who (an email, or api:<key name>), the verb and route, the answer, allowed or refused and why — and, for the settings that matter (branding and policies, the support assistant, exception rules, webhooks, API keys, team roles, a proof of delivery), what changed as before and after. One row per action, newest first, paged by cursor; changes=1 keeps only the rows that changed something. Secrets never appear: a key is audited by its name and role, an endpoint by its mode and header name. Admin and operations. GET /audit/staff lists what MARG's own staff did on your account (admin).

→ { "entries": [{ "at": "…", "actor": "ops@yourstore.in", "actorKind": "user", "method": "PUT", "route": "/support/policy", "status": 200, "allowed": true,
                 "entity": "support_policy", "before": { "enabled": false, … }, "after": { "enabled": true, … } }], "nextCursor": "…" }
GET/pod/:awb · GET/shipments/:id/pod · POST/pod/:awb · POST/shipments/:id/pod · GET/shipments/:id/pod/file

Proof of delivery as one answer: the courier's own, when its payloads carried a URL (carrierUrl), and a person's upload — a rider's photo, a signed slip — sent as { "data": "<base64>", "note"? }, judged by its bytes (JPEG, PNG, WebP or PDF, up to 4 MB), kept in MARG one per parcel, replaced in place with the replacement audited, and served only here. source is manual, carrier or null; file is where the upload is.

→ { "awb": "…", "status": "DELIVERED", "deliveredAt": "…", "source": "manual", "carrierUrl": null,
     "manual": { "contentType": "image/jpeg", "bytes": 184321, "note": "signed slip", "uploadedBy": "ops@yourstore.in", "uploadedAt": "…" }, "file": "/shipments/…/pod/file" }

Inventory

What you sell and how many you have, where. Products are learned from your orders' own lines, so the catalogue fills itself; stock is a ledger — every change is a movement with a reason, and the level is its sum.

CallWhat it does
GET /inventory/products?q=&low=1the catalogue with on hand, held by orders, fenced by pools and sellable
GET /inventory/products/{id|sku}one product: levels per warehouse and bin, pools, reservations, units, the last movements, days of cover
POST /inventory/products, PUT /inventory/products/{id}create or change one; components makes it a bundle
POST /inventory/products/importa product sheet as { csv }sku is the only required column; money in rupees
GET /inventory/stocklevels per product, warehouse and bin
POST /inventory/stock/adjust{ sku, warehouseId, qty, kind, note } to move by a signed quantity, or { set } to record a count — with no binId, set is the warehouse's total
POST /inventory/stock/transfer, /stock/import, /stock/rebuildmove between bins or warehouses; a stock sheet (sku, warehouse, qty[, bin]); rebuild a level from its own movements and say whether the cache matched
GET /inventory/movementsthe ledger, filtered by product, kind, warehouse and dates
GET /inventory/snapshots, /projection, /alertsopening and closing balances per IST day; days of cover and a suggested order; low stock
GET /inventory/vendors, POST /inventory/vendorswho you buy from
GET /inventory/purchasing, /purchasing/suggestionsthe buying desk; what to order, from the pace of sales less what is already on an open order
POST /inventory/purchase-orders, GET /inventory/purchase-orders/{id}, /{id}/printraise an order (lines priced from what each product last landed at), read it, print it for the vendor
POST /inventory/receiptsa delivery arrived: { poId, warehouseId, lines: [{ sku, qtyGood, qtyRejected, rejectReason }] }. What is good goes on the shelf; what is rejected is recorded and does not
GET /inventory/facility/{orderId}which warehouse would ship this order, and why
GET/PUT /inventory/settingshold stock for open orders, refuse to ship what is not there, tell your stores, watch for low stock, choose which warehouse ships

The floor

CallWhat it does
GET /floor, /floor/pickabletoday's numbers and the open waves; the orders a wave could be built from
POST /floor/waves{ warehouseId, orderIds } — the walk is built from where the stock is, with a pigeonhole per order
GET /floor/waves/{id}, /waves/{id}/list.htmlthe walk and its progress; the printed pick list
POST /floor/waves/{id}/scan, /tasks/{id}/picka code at the shelf, or a line picked in full or short with a reason
POST /floor/pack/open, /pack/{id}/scan, /pack/{id}/completethe bench: open by order number, scan each item in, close with the weight. A box that does not match the order is refused unless you say why
Sellable = on hand − held by open orders − pools you fenced. Nothing is refused and no store is written to until you switch that on under Inventory → Settings.

Books (Tally and Busy)

Your tax invoices as vouchers your accountant imports. MARG builds the file and never signs into Tally or Busy — the gateway listens inside your office and the audit trail is yours. Check the period first: it names any voucher that would not balance, and the ledgers the file expects to find.

CallWhat it does
GET /accounting/settings, PUTyour ledger names, voucher types, and whether GST-registered buyers get their own ledger
GET /accounting/books?from=&to=what the period adds up to, any unbalanced voucher, and the ledgers it will use
GET /accounting/tally.xml?from=&to=the Tally import envelope (needs the export power)
GET /accounting/busy.csv?from=&to=the flat file for Busy (needs the export power)

Sellers

If you ship for other brands, each of them gets their own login at /seller and sees only their own lines of your orders, their own stock and their own statement — never a buyer, never an address, never another seller. A line belongs to whoever owns the SKU.

CallWhat it does
GET /sellers, GET /sellers/{id}who they are, their SKUs and logins, and what each is owed
POST /sellers, PUT /sellers/{id}{ code, name, commissionBp, payoutDays, … }
POST /sellers/skus{ sellerId | null, skus: [] } — whose SKUs are whose; null hands them back to you
POST /sellers/{id}/usersinvite a login; the code comes back once
POST /sellers/users/{id}/disableend a login

Risk weights

The RTO score is weighted named reasons, and MARG will re-weight the ones your own parcels can speak to — was this the buyer’s first finished parcel, had they returned one to you, how does this area behave for you. The network signals stay MARG’s: one merchant cannot re-weight what every merchant contributed. Every fit is measured on parcels it never saw and is used only if it separates returns better than MARG’s weights.

CallWhat it does
GET /rto/weightsevery reason with MARG’s number and yours, the evidence behind each, and the fits so far
POST /rto/refit{ windowDays?, adopt? } — fit now on your own finished parcels
POST /rto/fits/{id}/adopt, /revertuse a fit, or go back to MARG’s weights

Same-day and next-day lanes

Your own contracts, written down: from this origin to that destination, ordered by this hour, it lands by that one. The delivery date, the checkout line and the buyer’s tracking page all read the lane before they read a courier’s TAT sheet, and booking writes the promised hour onto the parcel so a late delivery can be argued from it.

CallWhat it does
GET /metrothe lanes, the metros MARG can name, and how well the promise is being kept
POST /metro/lanes{ fromPrefix | warehouseId, toPrefix, service: "same_day"|"next_day", cutoffMinute, promiseHour, carrierCode?, label? }
PUT /metro/lanes/{id}, DELETE /metro/lanes/{id}change it, turn it off, remove it
GET /metro/check?from=&to=would this go today? — the lane and the promise at this moment, cut-off said out loud
GET /metro/missedparcels that missed the hour, with how late they were

Confirm before shipping

A WhatsApp message with buttons a few minutes before the parcel goes out — Yes, send it · My address is wrong · Cancel my order. The buyer's tap confirms the order, applies a corrected address, or cancels it, through the same doors your own staff use. When you ask MARG to hold, POST /ship answers awaiting_confirmation until they reply — and the hold lapses on its own, so nothing is ever stuck behind a question.

CallWhat it does
GET /orders/confirm-askthe settings, and the buttons they add up to
PUT /orders/confirm-askpatch any of them: enabled, ask (needs_call / all_cod / all), afterMinutes, askAddress, allowCancel, hold, holdHours, quiet hours, templateName
GET /orders/confirm-asksthe numbers — asked, waiting, confirmed, cancelled, corrected, held — and the list
POST /orders/{id}/confirm-askask about this one now, whatever the sweep thinks
POST /orders/{id}/confirm-ask/answer{ verb: "confirm" | "cancel", note? } — the buyer rang the store instead of tapping

Reviews

Product reviews left on your tracking page by buyers whose parcel actually arrived — one per item per parcel, revised in place for thirty days. There is no call that writes one: a review you or we could write is not a review. Switch the collection on under the brand studio; the buyer's name is stored only as a first name and an initial.

CallWhat it does
GET /reviews?sku=&published=the numbers and the list, newest first, with the order behind each one
POST /reviews/{id}/hide{ reason? } — taken off the tracking page, with the reason kept beside it
POST /reviews/{id}/showput it back

Stores

Stock moved between your facilities as a document — what left, what is still on the van, what arrived — and orders a buyer collects from a shop instead of waiting for a courier. A transfer's two ends are written through the stock ledger, so while it is out the units are counted at neither facility. A collection books no courier: the order is picked from that shop's own stock and handed over against a code.

CallWhat it does
GET /omnithe tiles, your stock-keeping facilities, the open transfers and what is waiting at a counter
POST /omni/transfers{ fromWarehouseId, toWarehouseId, lines: [{ sku, qty }], note? } — the paperwork; nothing has moved yet
GET /omni/transfers, /{id}the documents, with what is sent, received and still in transit
POST /omni/transfers/{id}/dispatchwhat is actually on the shelf goes; anything short is recorded on its line
POST /omni/transfers/{id}/receive{ lines: [{ sku, qty, shortReason? }] } — a partial count leaves the rest in transit
POST /omni/transfers/{id}/cancelputs back exactly what is on the van, at the facility it left
GET /omni/aisle?sku=&from=who has this SKU sellable, nearest to the asking facility first
POST /omni/collections{ orderId, facilityId } — the shop fulfils it and the buyer gets a code; refused when a parcel is already on its way
GET /omni/collectionswhat is standing at the counters
POST /omni/collections/{id}/readypacked and on the counter — the buyer's tracking page says so
POST /omni/collections/{id}/hand-over{ code } — the code must match; the units leave that shop's shelf and the order is finished
POST /omni/collections/{id}/ship-insteadback to a courier; the collection code stops working

Tax invoices

The GST document for an order — CGST and SGST inside a state, IGST across one, decided on the delivery state. MARG builds the payloads the e-invoice and e-way bill portals take; it never calls them, and the numbers they issue are recorded here when your own gateway returns them.

CallWhat it does
GET /invoicesthe list with the tiles, and what still owes an e-way bill
POST /invoices{ orderId, buyerGstin? } — idempotent: an order that has one gets it back
GET /invoices/{id}, /{id}/printthe invoice with its lines; the printable document
GET /invoices/{id}/einvoice.json, /{id}/ewaybill.jsonthe portal's payload — or a 422 naming exactly what is missing
GET /invoices/ewaybills.csveverything above your threshold, in the portal's bulk column order
PUT /invoices/{id}/irn, /{id}/ewaybillrecord what your gateway returned: the IRN and signed QR, the e-way bill number and its validity
POST /invoices/{id}/cancelwith a reason — the portals ask for one too

Public endpoints

No key, no login, CORS * — these are called from a buyer’s browser on your own storefront, or by anyone at all.

CallWhat it does
GET /public/edd?ref=&pincode=the delivery date for your product page
GET /public/edd/checkout?ref=&pincode=the same date at the checkout, in your own words, with a countdown to your cut-off (never cached)
GET /public/edd/checkout.js?ref=the drop-in that shows it — one <script> in your theme; it removes itself on any error
GET /public/tools/quotel,w,h,weightG,divisor,slabG,from,to,valuePaise,cod — volumetric weight, billable weight and the zone
GET /toolsthe calculators as a page anyone can use

Errors

CodeMeaning
400the body or query is malformed — error says what, issues names the fields
401 / 403no key, a revoked key, or a key whose role or power does not cover this call — or a feature switch: a capability MARG has not switched on for your account answers 403 with error in words (“… is not enabled for your account — ask MARG to switch it on.”) and feature naming it, on every write to that feature's calls and on the reads of the reporting and audit APIs; GET /features lists what is on for your account
404no such row in your store
409the state does not allow it — already decided, already booked, already held
422the request was understood and refused for a reason in error — a courier that cannot take the parcel, a held order
429over the key's rate limit

MARG Ship · Privacy · Terms