Push products, prices, inventory, warehouses and your own profile into the MedGrid Marketplace. MedGrid pushes orders and approval results back to your system via signed webhooks. One REST surface, bearer-token auth, JSON in, JSON out. Every endpoint here ships in the live codebase.
# Exchange credentials for a 1-hour bearer token curl -X POST \ -H "Content-Type: application/json" \ -d '{ "client_id": "mgk_...", "client_secret": "..." }' \ "https://medgrid.com/api/method/medgrid.api.v1.auth.token"
Upsert products and selling prices in single calls or batches. New products enter Pending Approval and publish once reviewed. Deduplication by SKU or your own external product id.
Catalog APIs →Mirror warehouse stock from your own system and manage the warehouses you own. Stale-snapshot protection means out-of-order updates never overwrite newer stock.
Inventory APIs →Receive order webhooks when your products sell and report shipment tracking back to MedGrid. Available to every vendor class — not just pharmacies.
Fulfillment APIs →MedGrid sends signed webhook events to your system — product approval results and new orders. Verify the X-MedGrid-Signature header on every inbound request.
Generate API credentials once on your Vendor Profile, exchange them for a 1-hour bearer token, then call the sync and pharmacy endpoints with that token. All endpoints live under https://medgrid.com/api/method/medgrid.api.v1.*.
Open your Vendor Profile → API Integration → Inbound API (Vendor → MedGrid). Tick API Enabled and click Generate API Credentials. Copy all three values shown: client_id, client_secret, and webhook_secret — they are shown only once.
webhook_secret is used to verify the HMAC signature on every inbound webhook from MedGrid. Never expose any credential in browser code or version control.Token requests succeed only when your profile is API Enabled, in workflow_state = Approved, and not Suspended. Status is re-checked on every request, so revocation is immediate — not only at token issue.
Authorization: Bearer <access_token> Content-Type: application/json
Exchange your client credentials for a 1-hour bearer JWT scoped to your vendor account. Every token request (success or failure) is recorded in the Vendor API Log.
{
"client_id": "mgk_175c4b0033b2bcb6cd0dc22b",
"client_secret": "your-client-secret"
}
| Field | Type | Description | |
|---|---|---|---|
client_id | string | required | Public client identifier (mgk_…). |
client_secret | string | required | The secret shown once at generation. |
{
"message": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "sync"
}
}
"sync pharmacy". Tokens expire after 1 hour — refresh proactively or request a new one when you receive a 401.Exchange a still-valid bearer token for a fresh 1-hour token — without re-sending your client_secret. The vendor's live status (Approved, not Suspended, API enabled) is re-checked on every refresh.
No body required. Pass your current token in the Authorization header.
Authorization: Bearer <current_access_token> Content-Type: application/json
{
"message": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "sync"
}
}
auth.token. Refresh is recorded in the Vendor API Log.auth.token requests within a 15-minute window, the client_id is locked out for 15 minutes. Locked-out requests return 429 Too Many Requests with a Retry-After: <seconds> header. The counter resets on a successful authentication.Patterns shared by every vendor endpoint.
Most vendor API endpoints are POST only. Three also accept GET, for the read they perform: sync.warehouses, sync.orders and sync.product_status. Calling any other endpoint with GET returns 403 Not permitted.
Every response wraps the return value in a message key.
{ "message": { ...result... } }
The product, price and inventory endpoints accept either a single object or a list. A list returns a per-record success/failure breakdown — see Batch operations.
Datetimes use ISO 8601 (2026-06-26T10:00:00); dates use YYYY-MM-DD.
sync is available to every API-enabled vendor. pharmacy is restricted to 503A / 503B pharmacy vendors and unlocks Rx product attributes plus the fulfillment callback. Non-pharmacy vendors using a pharmacy field or endpoint are rejected with 422.Push your product catalog and selling prices into the marketplace. New products enter Pending Approval and are not published until approved (unless your account is set to auto-approve).
Create or update one or more products (Items). Deduplication is by SKU, then by (vendor, vendor_product_id). Accepts a single object or a list.
{
"products": [
{
"sku": "SKY_SKU_1",
"name": "Skydell Test Item",
"description": "30-count bottle",
"group": "Products",
"price": 19.99,
"cost": 8.50,
"vendor_product_id": "EXT-1001",
"price_list": "Standard Selling"
}
]
}
| Field | Type | Description | |
|---|---|---|---|
sku / item_code | string | required* | Item code. *Either sku or vendor_product_id is required. |
vendor_product_id | string | required* | Your external product id, used for dedupe. |
name | string | optional | Item display name. |
description | string | optional | Long description. |
image | string | optional | Image URL. |
group | string | optional | Item group (auto-created if new). |
price | number | optional | Standard rate. |
cost | number | optional | Valuation rate. |
price_list | string | optional | If set together with price, a selling Item Price is written in the same call. |
uom | string | optional | Unit the product is sold in. Set only when the item is first created — later calls cannot change it. Must already exist in MedGrid: an unknown unit is refused with 417 rather than silently swapped. Defaults to Nos. |
{
"message": {
"total": 1, "succeeded": 1, "failed": 0,
"results": [
{ "index": 0, "ok": true,
"result": { "sku": "SKY_SKU_1", "created": true, "workflow_state": "Pending", "published": false } }
]
}
}
Sending a single object instead of a list returns the bare result object (no batch wrapper).
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. A non-pharmacy vendor sending any of them gets 422.Upsert a selling Item Price for one or more products. An unknown price list is rejected with 417.
{
"prices": [
{ "sku": "SKY_SKU_1", "price": 24.50, "price_list": "Standard Selling" }
]
}
| Field | Type | Description | |
|---|---|---|---|
sku | string | required | Must be an existing Item owned by your vendor account. |
price_list | string | required | Must exist, else 417. |
price | number | required | Selling price. Must be ≥ 0 — negative values are rejected with 417. |
{ "message": { "sku": "SKY_SKU_1", "price_list": "Standard Selling", "price": 24.5, "uom": "Nos" } }
uom.Check the MedGrid approval state of one or more products you own. Look up by sku (Item code) or vendor_product_id. Batch up to 500 items per call. Useful for polling after initial sync without subscribing to webhooks.
{ "sku": "SKY_SKU_1" }
{
"items": [
{ "sku": "SKY_SKU_1" },
{ "vendor_product_id": "EXT-1002" }
]
}
| Field | Type | Description | |
|---|---|---|---|
sku | string | required* | Item code. *Either sku or vendor_product_id required per item. |
vendor_product_id | string | required* | Your external product id. |
items | array | optional | Batch lookup — max 500 items. |
{
"message": {
"sku": "SKY_SKU_1",
"item_name": "Nitrile Gloves L",
"workflow_state": "Approved",
"published": true,
"found": true
}
}
{
"message": {
"total": 2,
"results": [
{ "sku": "SKY_SKU_1", "item_name": "Nitrile Gloves L", "workflow_state": "Approved", "published": true, "found": true },
{ "vendor_product_id": "EXT-1002", "found": false }
]
}
}
"found": false. A found item also echoes your vendor_product_id. You only need to poll here if you prefer not to receive product.approved / product.rejected webhooks.total and no results wrapper.Mirror your warehouse stock and manage the warehouses you own. A vendor can only ever read or write its own warehouses — a warehouse owned by another vendor (or by the company) is rejected with 403.
Set on-hand quantity for one of your warehouses. The warehouse is auto-created on first use. Older snapshots never overwrite newer stock.
{
"inventory": [
{
"sku": "SKY_SKU_1",
"warehouse": "SKY_WH_1 - MG",
"qty": 100,
"snapshot_at": "2026-06-26T10:00:00"
}
]
}
| Field | Type | Description | |
|---|---|---|---|
sku | string | required | Must be an existing Item owned by your vendor account. |
warehouse | string | required | A warehouse you own. Auto-created if it doesn't exist; 403 if owned by another vendor. |
qty | number | required | New on-hand quantity. Must be ≥ 0 — negative values are rejected with 417. |
snapshot_at | datetime | optional | ISO 8601 timestamp. Defaults to now. Older than the stored snapshot → rejected (stale). |
{
"message": {
"sku": "SKY_SKU_1", "warehouse": "SKY_WH_1 - MG",
"qty": 100.0, "applied": true, "snapshot_at": "2026-06-26T10:00:00"
}
}
{
"message": {
"sku": "SKY_SKU_1", "warehouse": "SKY_WH_1 - MG",
"applied": false, "reason": "stale_snapshot",
"current_snapshot_at": "2026-06-26T10:00:00",
"current_qty": 85.0
}
}
List the warehouses you own, or create a new one. Created warehouses are stamped with your vendor id (mg_vendor) — only you can write inventory into them.
name){}
// →
{
"message": {
"vendor": "s299avsi27",
"warehouses": [
{ "name": "SKY_WH_1 - MG", "warehouse_name": "SKY_WH_1", "company": "MedGrid", "disabled": 0 }
]
}
}
name{ "name": "SKY_WH_2" }
// →
{ "message": { "warehouse": "SKY_WH_2 - MG", "created": true } }
| Field | Type | Description | |
|---|---|---|---|
name | string | optional | If given, creates (or returns) a warehouse you own. If omitted, lists your warehouses. The company abbreviation is appended automatically to the stored name. |
Update your own Vendor Profile. Pharmacy compliance fields are writable only by 503A/503B vendors — a non-pharmacy vendor sending any pharmacy-only field gets 422.
Always a single object — the caller's own profile.
{
"details": {
"webhook_url": "https://skydell.test/hook",
"phone": "555-0100",
"address": "1 Main St",
"zip": "10001"
}
}
| Field | Type | Pharmacy-only | Description |
|---|---|---|---|
webhook_url | string | No | Where MedGrid posts HMAC-signed outbound events. |
phone | string | No | Contact phone. |
address | string | No | Street address. |
zip | string | No | Postal code. |
npi | string | Yes | 10-digit National Provider Identifier. |
dea_number | string | Yes | DEA registration number. |
ncpdp_id | string | Yes | NCPDP identifier. |
state_license | string | Yes | State pharmacy license number. |
license_expiry | date | Yes | License expiry date. |
controlled_substance | bool | Yes | Handles controlled substances. |
{ "message": { "vendor": "s299avsi27", "updated": ["address", "phone", "webhook_url", "zip"] } }
After MedGrid sends you an order.new webhook, confirm and ship the order in your system, then POST tracking back here. Available to every vendor class — Supplier, Distributor, Manufacturer, 503A, 503B.
List recent orders containing your items, newest first. This is the recovery path for order.new / order.cancelled webhooks your endpoint missed — each order here carries the same body the webhook would have delivered, so you can reconcile without a replay request.
GET /api/method/medgrid.api.v1.sync.orders?since=2026-08-01&status=submitted&page=1&page_length=20
| Field | Type | Description | |
|---|---|---|---|
since | date / datetime | optional | Only orders modified at or after this point. Defaults to the last 7 days — reach further back explicitly after a long outage. |
status | string | optional | submitted or cancelled. Omit for both. |
page | number | optional | 1-based page number. Default 1. |
page_length | number | optional | Orders per page. Default 20, capped at 100 — a larger value is clamped, not rejected. |
{
"message": {
"total": 42,
"page": 1,
"page_length": 20,
"since": "2026-08-01 00:00:00",
"orders": [
{
"sales_order": "SO-2026-00456",
"status": "submitted",
"order_date": "2026-08-14",
"delivery_date": "2026-08-20",
"currency": "USD",
"ship_to": { /* same shape as order.new */ },
"line_items": [ /* only your items */ ]
}
]
}
}
total counts orders, not the rows on this page — use it to size your paging loop. As with the webhooks, you only ever see your own line items; another vendor's items on the same order are never returned.since window — which is what makes this work as a catch-up.Report shipment and tracking for a Sales Order. You may only report on orders containing your own products (403 otherwise). Repeated calls with the same sales_order update the same tracking row (keyed on source: "Vendor API").
{
"data": {
"sales_order": "SO-2026-00456",
"tracking_number": "1Z999AA10123456784",
"carrier": "UPS",
"status": "shipped",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
"estimated_delivery_date": "2026-07-05"
}
}
| 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 auto-derive a tracking URL when tracking_url is blank. |
tracking_url | string | optional | Auto-derived from carrier + tracking_number if blank. |
status | string | optional | Your carrier status text — matched to a MedGrid tracking stage (see below). Anything unrecognised becomes Pending rather than an error, so check the tracking_status you get back. |
estimated_delivery_date | date | optional | Expected delivery date (YYYY-MM-DD). |
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 — otherwise every past shipment mails out again. |
{
"message": {
"sales_order": "SO-2026-00456",
"tracking_number": "1Z999AA10123456784",
"tracking_status": "Dispatched"
}
}
tracking_status is the stage MedGrid resolved, not the status you sent — "shipped" comes back as "Dispatched".Your status 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 — in_transit and in-transit both work. The first row below that matches wins.
| Stage | Matched when your status contains |
|---|---|
Exception | undeliverable, not delivered, cancel, refund, fail, exception, hold, error — checked first, so a failure never completes an order |
Out for Delivery | out for delivery |
Delivered | delivered anywhere in the text; or the exact strings delivery complete / delivery completed |
In Transit | transit |
Dispatched | ship, dispatch, label |
Pending | anything else, including a blank or omitted status |
503A/503B vendors may also use the pharmacy-scoped fulfillment endpoint below. It is functionally identical to sync.fulfillment above but is gated to the pharmacy scope. New integrations should prefer sync.fulfillment.
Restricted to 503A/503B vendors (422 otherwise). You may only report on orders containing your own products (403 otherwise).
{
"data": {
"sales_order": "SO-2026-00456",
"tracking_number": "1Z999AA10123456784",
"carrier": "UPS",
"status": "Shipped",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
"estimated_delivery_date": "2026-06-30"
}
}
| Field | Type | Description | |
|---|---|---|---|
sales_order | string | required | Must exist and contain one of your items. |
tracking_number | string | optional | Defaults to "Awaiting tracking" if omitted. |
carrier | string | optional | Used to derive a tracking URL. |
tracking_url | string | optional | Auto-derived from carrier + number if blank. |
status | string | optional | Matched to a MedGrid tracking stage — same vocabulary as sync.fulfillment above. |
estimated_delivery_date | date | optional | ETA. |
notify | bool | optional | Defaults to true — emails the customer when a tracking number is present. Send false to suppress. |
{
"message": {
"sales_order": "SO-2026-00456",
"tracking_number": "1Z999AA10123456784",
"tracking_status": "Dispatched"
}
}
MedGrid posts signed JSON events to the Webhook URL on your Vendor Profile. Configure it via Vendor Profile → API Integration → Inbound API. Verify every request before processing it.
Every request carries an X-MedGrid-Signature header. Compute sha256=HMAC-SHA256(webhook_secret, raw_request_body) and compare. Reject any request where the values do not match.
# Python example 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)
All events share the same top-level envelope.
{
"event": "order.new", // event type
"vendor": "VP-00023", // your Vendor Profile name
"created_at": "2026-06-29 14:02:11", // server-local, no offset — not UTC
"attempt": 1, // delivery attempt number (1–3)
"data": { /* event-specific payload — see below */ }
}
X-MedGrid-Attempt HTTP header with the attempt number. If your endpoint returns a non-2xx response or times out after 10 seconds, MedGrid re-delivers it. There are three attempts in total — the original plus two retries, the first about 60 s later and the second about 5 min after that. Always respond within 10 seconds — use a background queue if processing takes longer.product.approvedFired when a MedGrid Admin changes an item's workflow state to Approved. The item is now live on the marketplace.
{
"event": "product.approved",
"vendor": "VP-00023",
"created_at": "2026-06-29 14:02:11",
"attempt": 1,
"data": {
"sku": "ITM-00831", // MedGrid Item code
"item_name": "Nitrile Gloves L",
"vendor_product_id": "SKY-GLOVE-L", // your stable key (blank if not set)
"workflow_state": "Approved",
"previous_state": "Pending"
}
}
product.rejectedFired when a MedGrid Admin changes an item's workflow state to Rejected. The item is not published. Check rejection_reason and re-submit after correcting the issue.
{
"event": "product.rejected",
"vendor": "VP-00023",
"created_at": "2026-06-29 14:09:44",
"attempt": 1,
"data": {
"sku": "ITM-00831",
"item_name": "Nitrile Gloves L",
"vendor_product_id": "SKY-GLOVE-L",
"workflow_state": "Rejected",
"previous_state": "Pending",
"rejection_reason": "Missing NDC number" // blank if no reason was recorded
}
}
| Field | Type | Notes |
|---|---|---|
sku | string | MedGrid Item code. |
vendor_product_id | string or blank | The stable key you sent in the sync call. |
workflow_state | string | New state: Approved or Rejected. |
previous_state | string | State before the admin action (e.g. Pending). |
rejection_reason | string | Human-readable reason set by the Admin. Present only on product.rejected. |
sync.product_status instead of waiting for these webhooks — useful during initial integration testing.order.newFired when a Sales Order containing your products is submitted. The payload contains only your line items — other vendors' items are never exposed. Respond by fulfilling the order and calling sync.fulfillment with tracking.
{
"event": "order.new",
"vendor": "VP-00023",
"created_at": "2026-06-29 14:05:33",
"data": {
"sales_order": "SO-2026-00456",
"order_date": "2026-06-29",
"delivery_date": "2026-07-05",
"currency": "USD",
"ship_to": {
"address_line1": "500 Main St",
"address_line2": "",
"city": "Boston",
"state": "MA",
"pincode": "02101",
"country": "United States"
},
"line_items": [
{
"item_code": "ITM-00831",
"item_name": "Nitrile Gloves L",
"sku": "ITM-00831",
"qty": 2,
"uom": "Box",
"rate": 14.99,
"amount": 29.98,
"warehouse": "Finished Goods - MG"
}
]
}
}
| Field | Type | Notes |
|---|---|---|
sales_order | string | Pass this to sync.fulfillment when you ship. |
ship_to | object | Empty object if no shipping address is set on the order. |
line_items | array | Only items belonging to your vendor account. |
rate / amount | number | Selling price per unit and line total in currency. |
2xx status within 10 seconds. If your endpoint is slow or unavailable, MedGrid re-delivers twice more — after about 60 s, then about 5 min. All three attempts are logged in the Vendor API Log.order.cancelledFired when a Sales Order is cancelled after being submitted. Stop any in-progress fulfillment and reconcile stock if goods have already shipped.
{
"event": "order.cancelled",
"vendor": "VP-00023",
"created_at": "2026-06-29 15:30:00",
"attempt": 1,
"data": {
"sales_order": "SO-2026-00456",
"cancelled_at": "2026-06-29 15:29:58",
"order_date": "2026-06-29",
"currency": "USD",
"line_items": [
{
"item_code": "ITM-00831",
"item_name": "Nitrile Gloves L",
"sku": "ITM-00831",
"qty": 2,
"uom": "Box",
"rate": 14.99,
"amount": 29.98,
"warehouse": "Finished Goods - MG"
}
]
}
}
| Field | Type | Notes |
|---|---|---|
sales_order | string | The same order name sent in order.new. |
cancelled_at | datetime | When the order was cancelled, in MedGrid's configured server timezone. The stamp carries no offset — do not parse it as UTC. |
line_items | array | Only your items — same shape as order.new. |
Deliver a signed webhook.test event to your configured Webhook 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.
No body. Pass your token in the Authorization header.
{
"message": {
"ok": true,
"status": 200, // the status YOUR endpoint returned
"event": "webhook.test",
"attempt": 1
}
}
{ "ok": false, "reason": "no_webhook_url" } (or "no_webhook_secret") rather than an error — check ok, not the HTTP status of the call itself.data.message set to a fixed test string — safe for your handler to ignore.Idempotency, batch processing, error codes and rate 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 the header — sync.orders, sync.product_status and listing warehouses are already repeatable.
| Behaviour | Result |
|---|---|
| Replay — same key + same body | Returns the original response with "idempotent_replay": true added. |
| Conflict — same key + different body | Returns 409. |
| In flight — same key, still running | Returns 409 once the first request has held the key for 8 s. Retry with the same key; you get the stored response, never a second execution. |
| TTL | Stored results expire after 24 hours. |
{
"message": {
"sku": "SKY_IDEM", "created": true,
"workflow_state": "Pending", "published": false,
"idempotent_replay": true
}
}
Send a list to sync.products, sync.prices or sync.inventory to process many records in one call. Each record is isolated with a savepoint — one failure never rolls back the others.
{
"products": [
{ "sku": "SKY_B_OK", "name": "Good Row", "price": 5 },
{ "sku": "", "name": "Bad Row" }
]
}
{
"message": {
"total": 2, "succeeded": 1, "failed": 1,
"results": [
{ "index": 0, "ok": true,
"result": { "sku": "SKY_B_OK", "created": true, "workflow_state": "Pending", "published": false } },
{ "index": 1, "ok": false,
"error": "sku or vendor_product_id is required", "error_type": "ValidationError" }
]
}
}
| Status | Meaning | Typical cause |
|---|---|---|
200 | OK | Request succeeded. |
401 | Unauthorized | Missing, malformed, invalid or expired bearer token; bad client credentials. |
403 | Forbidden | API disabled / vendor not Approved / suspended; warehouse owned by another vendor; order not yours; wrong HTTP method. |
409 | Conflict | Two cases, 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 s) — for the second, retry with the same key to collect the stored response. |
417 | Expectation Failed | Validation error — missing/blank body, unknown item, unknown price list, unknown warehouse. |
422 | Unprocessable Entity | Pharmacy-only field or endpoint used by a non-pharmacy vendor. |
429 | Too Many Requests | Rate limit exceeded or brute-force lockout active. Check the Retry-After response header. |
{
"exception": "frappe.exceptions.PermissionError: Warehouse SKY_WH_1 - MG is not owned by your vendor account",
"exc_type": "PermissionError"
}
Requests are rate-limited per authenticated vendor. Exceeding the limit returns 429 Too Many Requests with a Retry-After: <seconds> header indicating when you may retry.
| Rule | Limit |
|---|---|
| Inbound API calls per vendor | 120 requests / minute |
| Batch records per request | 500 max, on every batch endpoint — sync.products, sync.prices, sync.inventory and sync.product_status. A larger list is rejected outright with 429; split it into 500-record calls. |
| Webhook delivery | 10 seconds per attempt; 3 attempts max — the original, then retries after about 60 s and 5 min |
429, read the Retry-After header and wait that many seconds before retrying. Always combine retries with an Idempotency-Key so duplicate requests are safely de-duplicated.Our integrations team will pair with you on schema, test data, and webhook setup — reach out through the contact page and choose "Platform Support".
Contact the integrations team