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.
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
| Field | Type | | Description |
|---|
client_id | string | required | Public client identifier (mgk_…). |
client_secret | string | required | The 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".
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.
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)
| Field | Type | | Description |
|---|
sku | string | required* | 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_id | string | required* | 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_code | string | optional | The MedGrid item code, for a product we set up for you. Must agree with sku and vendor_product_id if you send them. |
name | string | optional | Item display name. |
description | string | optional | Long description. |
group | string | optional | Category. 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. |
price | number | optional | Standard rate. |
cost | number | optional | Valuation rate. |
price_list | string | optional | With price, writes a selling Item Price in the same call. |
uom | string | optional | Set 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. |
disabled | 0 / 1 | optional | 1 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.
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)
| Field | Type | | Description |
|---|
sku | string | required* | 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_id | string | required* | Instead of sku. |
medgrid_item_code | string | required* | Instead of sku: the MedGrid item code. |
price_list | string | required | Must exist, else 417. |
price | number | required | Must 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.
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.
Orders & fulfilment
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
| Field | Type | | Description |
|---|
since | date / datetime | optional | Defaults 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. |
status | string | optional | submitted or cancelled. Omit for both. |
page | number | optional | 1-based. Default 1. |
page_length | number | optional | Default 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.
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)
| Field | Type | | Description |
|---|
sales_order | string | required | Must exist and contain at least one of your items. |
tracking_number | string | optional | Defaults to "Awaiting tracking" if omitted. |
carrier | string | optional | Used to derive a tracking URL when tracking_url is blank. |
tracking_url | string | optional | Auto-derived from carrier and number if blank. |
status | string | optional | Your carrier status text, matched to a MedGrid stage — see below. |
estimated_delivery_date | date | optional | Expected delivery date. |
notify | bool | optional | Defaults 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. |
items | array | optional | Lot, 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.
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.
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.
| Header | Value |
|---|
| X-MedGrid-Signature | sha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook secret> |
| X-MedGrid-Event | The event type, e.g. order.new |
| X-MedGrid-Delivery-Id | Same as the body's id. Unchanged across retries — drop a delivery you have already processed |
| X-MedGrid-Attempt | 1, 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.
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.
| Rule | Limit |
|---|
| Inbound API calls per vendor | 120 requests / minute |
| Batch records per request | 500, on every batch endpoint |
| Webhook delivery | 10 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.