Vendors integrating their own system — suppliers, distributors, manufacturers and 503A/503B pharmacies.

Vendor API

Sync your catalog, prices and stock, receive signed order webhooks, and report fulfilment.

Authentication

Generate credentials once on your Vendor Profile, exchange them for a one-hour bearer token, then call the sync endpoints with it. Your profile must be API-enabled, Approved and not suspended — status is re-checked on every request, so revocation is immediate.

Two scopes. sync is available to every API-enabled vendor. pharmacy is restricted to 503A/503B vendors and unlocks the Rx product attributes plus the pharmacy fulfilment callback. A non-pharmacy vendor using either is rejected with 422.
After 10 failed auth.token requests in a 15-minute window, further attempts for that client_id return HTTP 401 (AuthenticationError) with Retry-After. Wait the indicated number of seconds before retrying, even with valid credentials. The window starts with the first failure; a successful authentication clears the failure count before lockout.

POSTauth.token

Exchange client credentials for a one-hour bearer JWT scoped to your vendor account.

https://medgrid.com/api/method/medgrid.api.v1.auth.token

Parameters

FieldTypeDescription
client_idstringrequiredPublic client identifier (mgk_…).
client_secretstringrequiredThe secret shown once at generation.

Response

{ "message": {
    "access_token": "eyJhbGciOiJIUzI1NiIs…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "scope": "sync"
} }
For a 503A/503B pharmacy the scope is "sync pharmacy".

POSTauth.refresh

Exchange a still-valid token for a fresh one without re-sending your client_secret. The existing token must not have expired.

https://medgrid.com/api/method/medgrid.api.v1.auth.refresh

Catalog sync

New products enter Pending and are not published until approved, unless your account is set to auto-approve. For products, prices and inventory, put a single record or a list of records under the endpoint's named wrapper key. A list returns a per-record breakdown, and one failing record never rolls back the others.

Send the request examples as JSON with Content-Type: application/json and Authorization: Bearer <token>. Keep the outer products, prices, inventory or data key shown in each example; the field tables describe the records inside it. Replace sample SKUs, price lists, warehouses and order ids with values from your account.
Batches are capped at 500 records on every batch endpoint — products, prices, inventory and product_status alike. A larger list is rejected outright with 429; split it into 500-record calls.
Re-sending a product is safe. An Approved product goes back to review only when its name, description, image or group changes; a new price or cost alone keeps it listed. A Rejected product stays rejected until a re-send changes one of those fields or its price, which resubmits it.
Your own codes. By default your sku is the MedGrid item code. If your account keeps SKUs separate (ask us to set an item code prefix), sku always means your SKU: a new product gets a MedGrid code such as RGN-<your sku>, and everything we send you names the product by your sku, with ours in medgrid_item_code. To name a product by our code, send medgrid_item_code. Every call also accepts your vendor_product_id. If the codes in one record name different products, the record is refused (417) rather than one product taking over the other.

POSTsync.productsscope: sync

Create or update one or more products. Deduplication is by SKU, then by (vendor, vendor_product_id).

https://medgrid.com/api/method/medgrid.api.v1.sync.products

Product fields (inside products)

FieldTypeDescription
skustringrequired*Your SKU. It becomes the MedGrid item code unless your account keeps SKUs separate (see Your own codes above). Either sku or vendor_product_id is required. item_code is accepted as an alias.
vendor_product_idstringrequired*Your internal product id, used for dedupe. It still finds the product after you correct its sku, and the new sku is kept.
medgrid_item_codestringoptionalThe MedGrid item code, for a product we set up for you. Must agree with sku and vendor_product_id if you send them.
namestringoptionalItem display name.
descriptionstringoptionalLong description.
groupstringoptionalCategory. Leave it out and our team files the product: a new product sent without one, or with a name we don't recognise, starts under Needs classification.
pricenumberoptionalStandard rate.
costnumberoptionalValuation rate.
price_liststringoptionalWith price, writes a selling Item Price in the same call.
uomstringoptionalSet only when the item is first created; later calls cannot change it. An unknown unit is refused with 417 rather than silently swapped. Defaults to Nos.
disabled0 / 1optional1 takes the product off the storefront; it stays unlisted through later re-sends and re-approval until you send 0, which lists it again if it is Approved. Omit to leave the listing as it is.

Request

{
  "products": [
    { "sku": "EXAMPLE-SKU", "name": "Example product", "uom": "Nos" }
  ]
}

Response

{ "message": {
    "total": 2, "succeeded": 1, "failed": 1,
    "results": [
      { "index": 0, "sku": "EXAMPLE-SKU", "ok": true, "status": "Pending", "error": null,
        "result": { "sku": "EXAMPLE-SKU", "created": true, "workflow_state": "Pending",
                    "published": false, "status": "Pending" } },
      { "index": 1, "sku": "OTHER-SKU", "ok": false, "status": null,
        "error": "Unknown price list: Retail", "error_type": "ValidationError" }
    ]
} }
Match outcomes by sku. status is Pending, Approved, Rejected, or Disabled for an Approved product that is not on the storefront.
Unlisting with disabled: 1 never deletes the product or blocks orders already placed for it.
Rx attributes (ndc, dosage_form, route, dea_schedule, cold_chain, lot_number, batch_number, coa_url, is_rx, is_controlled, strength) are accepted only from 503A/503B vendors; anyone else sending one gets 422.

POSTsync.pricesscope: sync

Upsert a selling Item Price. An unknown price list is rejected with 417.

https://medgrid.com/api/method/medgrid.api.v1.sync.prices

Price fields (inside prices)

FieldTypeDescription
skustringrequired*Your sku (the MedGrid item code unless your account keeps SKUs separate). One of sku, vendor_product_id or medgrid_item_code is required.
vendor_product_idstringrequired*Instead of sku.
medgrid_item_codestringrequired*Instead of sku: the MedGrid item code.
price_liststringrequiredMust exist, else 417.
pricenumberrequiredMust be zero or greater.

Request

{
  "prices": [
    { "sku": "EXAMPLE-SKU", "price_list": "Your Selling Price List", "price": 25.00 }
  ]
}
The price is written against the item's stock UOM, echoed back as uom.

GETPOSTsync.product_statusscope: sync

Approval and publish state for one or more of your items — the polling alternative to the product webhooks.

https://medgrid.com/api/method/medgrid.api.v1.sync.product_status

Response

{ "message": {
    "sku": "EXAMPLE-SKU", "vendor_product_id": "variant-123", "item_name": "Example product",
    "workflow_state": "Rejected", "published": false, "status": "Rejected",
    "rejection_reason": "Insufficient or unclear product description — Add the strength",
    "found": true
} }
status is Pending, Approved, Rejected, or Disabled for an Approved product that is not on the storefront. rejection_reason is the reviewer's reason and comment, set only while the product is Rejected.
Branch on the shape, not on what you sent: a batch that resolves to exactly one item returns that item directly, with no total and no results wrapper.
Items owned by another vendor come back as found: false rather than an error.

Inventory & warehouses

POSTsync.inventoryscope: sync

Set on-hand quantity for one of your warehouses. The warehouse is auto-created on first use.

https://medgrid.com/api/method/medgrid.api.v1.sync.inventory

Inventory fields (inside inventory)

FieldTypeDescription
skustringrequired*Your sku (the MedGrid item code unless your account keeps SKUs separate). One of sku, vendor_product_id or medgrid_item_code is required.
vendor_product_idstringrequired*Instead of sku.
medgrid_item_codestringrequired*Instead of sku: the MedGrid item code.
warehousestringrequiredA warehouse you own; one owned by another vendor is 403.
qtynumberrequiredNew on-hand quantity, zero or greater.
snapshot_atdatetimeoptionalISO 8601. With an offset (Z, -05:00) it is converted to MedGrid server time; without one it is read as server time (US Eastern). Defaults to now. Older than the stored snapshot is rejected as stale.

Request

{
  "inventory": { "sku": "EXAMPLE-SKU", "warehouse": "Your Warehouse", "qty": 20 }
}
An older snapshot never overwrites newer stock — it comes back applied: false with reason: "stale_snapshot".

GETPOSTsync.warehousesscope: sync

List the warehouses you own, or create one by sending a name.

https://medgrid.com/api/method/medgrid.api.v1.sync.warehouses

Orders & fulfilment

GETPOSTsync.ordersscope: sync

Recent orders containing your items, newest first. This is the recovery path for order webhooks your endpoint missed — each order carries the same body the webhook would have delivered.

https://medgrid.com/api/method/medgrid.api.v1.sync.orders

Parameters

FieldTypeDescription
sincedate / datetimeoptionalDefaults to the last 7 days. With an offset (2026-10-02T13:00:00Z) it is converted to server time; without one it is read as MedGrid server time (US Eastern). Reach further back explicitly after a long outage.
statusstringoptionalsubmitted or cancelled. Omit for both.
pagenumberoptional1-based. Default 1.
page_lengthnumberoptionalDefault 20, capped at 100 — a larger value is clamped, not rejected.

Response

{ "message": {
    "total": 1, "page": 1, "page_length": 100, "since": "2026-10-01 09:00:00",
    "orders": [ {
      "sales_order": "SO-2026-0001",
      "status": "submitted",
      "docstatus": 1,
      "transaction_date": "2026-10-02",
      "order_date": "2026-10-02",
      "delivery_date": "2026-10-09",
      "currency": "USD",
      "shipping_method": "FedEx Priority Overnight",
      "lines_total": 178.0,
      "shipping_address": {
        "name": "Example Clinic", "contact_name": "Dr. Jane Smith",
        "address_line1": "100 Main St", "address_line2": "Suite 200",
        "city": "Austin", "state": "TX", "pincode": "78701", "country": "United States",
        "phone": "+1 512 555 0100", "email": "orders@example.com"
      },
      "ship_to": { "…": "same as shipping_address" },
      "items": [ { "item_code": "EXAMPLE-SKU", "sku": "EXAMPLE-SKU", "medgrid_item_code": "EXAMPLE-SKU",
                   "vendor_product_id": "your-id-123", "item_name": "Example product", "qty": 2, "uom": "Nos", "rate": 89.0, "amount": 178.0, "warehouse": "Your Warehouse" } ],
      "line_items": [ "…same as items" ]
    } ]
} }
Filtering is on last modified, not order date, so an order cancelled today appears in a recent window even if it was placed months ago. That is what makes it work as a catch-up.
Each order is the order.new webhook body, and only your lines. A cancelled order has status cancelled and docstatus 2. ship_to, line_items and order_date repeat shipping_address, items and transaction_date for older integrations.
item_code and sku name each line the way you do: your sku when your account keeps SKUs separate, else the MedGrid item code. medgrid_item_code is always ours.
shipping_address.name is the buying clinic, as it goes on the label; contact_name is the person who placed the order.
shipping_method is the method the clinic paid for on the shipment your lines leave in. It is blank when the order has none.
lines_total is the sum of amount over your lines. It is not the order total, which can include other vendors' lines, shipping and tax.

POSTsync.fulfillmentscope: sync

Report shipment and tracking for a Sales Order. You may only report on orders containing your own products.

https://medgrid.com/api/method/medgrid.api.v1.sync.fulfillment

Fulfilment fields (inside data)

FieldTypeDescription
sales_orderstringrequiredMust exist and contain at least one of your items.
tracking_numberstringoptionalDefaults to "Awaiting tracking" if omitted.
carrierstringoptionalUsed to derive a tracking URL when tracking_url is blank.
tracking_urlstringoptionalAuto-derived from carrier and number if blank.
statusstringoptionalYour carrier status text, matched to a MedGrid stage — see below.
estimated_delivery_datedateoptionalExpected delivery date.
notifybooloptionalDefaults to true, which emails the customer whenever a tracking number is present (patient and provider on a 503A order). Send false when backfilling historical tracking.
itemsarrayoptionalLot, serial and expiry per line shipped, for traceability and recalls. Each entry: sku (or medgrid_item_code or vendor_product_id), qty, lot_number, serial_numbers (a list), expiry_date (YYYY-MM-DD). Each entry needs a lot_number or serial_numbers. Send one entry per lot, so a line split across two lots is two entries.

Request

{
  "data": {
    "sales_order": "EXAMPLE-SO",
    "tracking_number": "YOUR-TRACKING-NUMBER",
    "carrier": "FedEx",
    "status": "shipped",
    "items": [
      { "sku": "EXAMPLE-SKU", "qty": 2, "lot_number": "L2026-118",
        "serial_numbers": ["SN-0001", "SN-0002"], "expiry_date": "2027-06-30" }
    ]
  }
}
tracking_status in the response is the stage MedGrid resolved, not the status you sent — "shipped" comes back as "Dispatched".
When you send items, the response adds lots_recorded. Sending items again for the same order and tracking number replaces what you sent before. A call without items leaves earlier lots as they are.
Every items entry is checked before anything is saved. Any of these refuses the whole call with 417: a product that isn't yours or isn't on the order; an entry with neither lot_number nor serial_numbers; a qty that isn't a number of zero or more; a lot or serial that isn't text or a whole number; an expiry_date that isn't a real YYYY-MM-DD date.

POSTpharmacy.fulfillmentscope: pharmacy

Functionally identical to sync.fulfillment but gated to 503A/503B vendors (422 otherwise). New integrations should prefer sync.fulfillment.

https://medgrid.com/api/method/medgrid.api.v1.pharmacy.fulfillment

Request

{
  "data": {
    "sales_order": "EXAMPLE-SO",
    "tracking_number": "YOUR-TRACKING-NUMBER",
    "carrier": "UPS",
    "status": "shipped"
  }
}
Use the same fulfilment fields as sync.fulfillment, inside the required data object.

Status vocabulary

Your status text is matched by keyword against the six MedGrid stages, so most carrier wording lands correctly without translation on your side. Matching ignores case and folds underscores and hyphens to spaces. The first row that matches wins.

StageMatched when your status contains
Exceptionundeliverable, not delivered, cancel, refund, fail, exception, hold, error — checked first, so a failure never completes an order
Out for Deliveryout for delivery
Delivereddelivered anywhere in the text; or exactly delivery complete / delivery completed
In Transittransit
Dispatchedship, dispatch, label
Pendinganything else, including a blank or omitted status
An unrecognised status becomes Pending rather than an error — "Picked Up", for instance. Check the tracking_status you get back rather than assuming your wording was understood.

Webhooks

MedGrid posts signed JSON events to the Webhook URL on your Vendor Profile: product.approved, product.rejected, order.new, order.cancelled and webhook.test. Verify every request before processing it, and answer 2xx to event types you do not use.

HeaderValue
X-MedGrid-Signaturesha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook secret>
X-MedGrid-EventThe event type, e.g. order.new
X-MedGrid-Delivery-IdSame as the body's id. Unchanged across retries — drop a delivery you have already processed
X-MedGrid-Attempt1, 2 or 3
Each header is also sent without the X- prefix (MedGrid-Signature, MedGrid-Event, MedGrid-Delivery-Id, MedGrid-Attempt) with the same value. Read whichever name your framework expects.

order.new

{
  "id": "whd_5f0c2a9e7b1d4c3a8e6f1b2c",
  "event": "order.new",
  "timestamp": "2026-10-02T18:03:00+00:00",
  "vendor": "your-vendor-profile-id",
  "created_at": "2026-10-02 14:03:00",
  "attempt": 1,
  "data": { "sales_order": "SO-2026-0001", "status": "submitted", "docstatus": 1,
            "shipping_address": { "name": "Example Clinic", "…": "…" },
            "items": [ { "item_code": "EXAMPLE-SKU", "qty": 2, "rate": 89.0, "…": "…" } ],
            "…": "the full order, as in sync.orders" }
}

product.rejected

{
  "id": "whd_…", "event": "product.rejected", "timestamp": "…", "attempt": 1,
  "data": { "sku": "EXAMPLE-SKU", "vendor_product_id": "variant-123", "item_name": "Example product",
            "workflow_state": "Rejected", "previous_state": "Pending",
            "reason": "Pricing is unrealistic — List price is below cost",
            "rejection_reason": "Pricing is unrealistic — List price is below cost" }
}
order.cancelled carries the same order body with status cancelled, docstatus 2 and cancelled_at. product.approved has no reason.

Signature verification

import hmac, hashlib

def verify(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
Respond with any 2xx within 10 seconds. There are three delivery attempts in total — the original plus two retries, the first about 60 seconds later and the second about 5 minutes after that. A Retry-After header on your response pushes the next attempt back, up to an hour. All attempts are logged in the Vendor API Log.
timestamp is UTC with an offset. created_at and cancelled_at are server-local timestamps with no offset — do not parse them as UTC.

POSTwebhooks.test_firescope: sync

Deliver a signed webhook.test event to your configured URL right now and get your own endpoint's HTTP response back. Use it to prove signature verification works before a real order depends on it.

https://medgrid.com/api/method/medgrid.api.v1.webhooks.test_fire

Synchronous and never retried, so the result you get back is the whole story. With no webhook URL or secret set it returns ok: false with a reason rather than an error — check ok, not the HTTP status of the call itself.

Idempotency & limits

Send an Idempotency-Key header to make any write safe to retry: sync.products, sync.prices, sync.inventory, sync.vendor_details, sync.fulfillment, pharmacy.fulfillment, and creating a warehouse. The reads ignore it — they are already repeatable. Stored results expire after 24 hours, and a replay returns the original response with idempotent_replay: true added.

RuleLimit
Inbound API calls per vendor120 requests / minute
Batch records per request500, on every batch endpoint
Webhook delivery10 seconds per attempt; 3 attempts — the original, then ~60 s and ~5 min
409 means two different things and the message says which: either the Idempotency-Key was reused with a different body, or a request carrying that key is still running (over 8 seconds). For the second, retry with the same key to collect the stored response.