Webhooks
Subscribe to events and MGX will POST a signed payload to your URL when they happen — so you do not have to poll. Requires the webhooks.read / webhooks.write scopes and a token bound to a team.
Create a subscription with the URL you want delivered to and the event types you care about. The SDKs expose the management endpoints (list/create/delete/deliveries) plus a verify() helper for the inbound side.
Create a subscription
const sub = await mgx.webhooks.create({
url: 'https://erp.example.com/mgx/hook',
events: ['trade.created', 'cashbid.offer_received'],
})
// The signing secret is returned only once — store it now.
console.log(sub?.id, sub?.secret)
Events
Each event is delivered to one side of the market. Buyer-side events go to the team that placed the bid (or owns the cash bid); seller-side events go to the teams of the user who owns the listing. A subscription only ever receives events for its own team — you will never see the counterparty's events.
- Name
bid.accepted- Type
- buyer-side
- Description
- A seller accepted your team's bid.
- Name
bid.rejected- Type
- buyer-side
- Description
- Your bid was rejected or the lot delisted.
- Name
bid.countered- Type
- buyer-side
- Description
- A seller countered your bid.
- Name
bid.received- Type
- seller-side
- Description
- A buyer placed a bid on one of your listings.
- Name
trade.created- Type
- both sides
- Description
- A bid became a trade. Delivered to the buying team and the seller's teams.
- Name
trade.settled- Type
- both sides
- Description
- Both invoices on a trade were paid. Delivered to the buying team and the seller's teams.
- Name
cashbid.offer_received- Type
- buyer-side
- Description
- A seller offered against your cash bid.
- Name
analysis.requested- Type
- partner
- Description
- A sample analysis was requested on an inventory you own or manage.
- Name
analysis.sample_received- Type
- partner
- Description
- MGX received the physical sample for grading (en route to SGS).
- Name
analysis.graded- Type
- partner
- Description
- Grading is complete — the grade and results are available.
- Name
analysis.photo_added- Type
- partner
- Description
- A graded sample photo was approved and is now visible.
- Name
inventory.matched- Type
- reserved
- Description
- Reserved. Accepted at subscription time but not yet emitted — no delivery of this type has ever been sent.
Handle unknown event types by ignoring them and still returning 2xx. Reserved types such as inventory.matched can be subscribed to today so that no redeploy is needed when they begin emitting, and new event types are added without a major version bump. A consumer that throws on an unrecognised type will start failing deliveries the day one ships.
The analysis.* events are only delivered to the partner team that requested the analysis through the Enterprise API. First-party/dashboard requests do not emit them. See Analysis.
There are no bid.retracted or listing.* lifecycle events yet. Seller integrations that mirror listing state should poll GET /v1/me/inventory — the status field is listed, unlisted, or archived. Trades are also readable from GET /v1/trades, scoped to trades your team bought or your user sold.
Delivery envelope
Every delivery is a POST with the same top-level envelope; the per-event fields documented below live under data.
- Name
id- Type
- string
- Description
- Unique id for this event, stable across retries and replays. Dedup on this — treat a repeat
idas already handled.
- Name
type- Type
- string
- Description
- The event type, e.g.
trade.created.
- Name
event- Type
- string
- Description
- Deprecated alias of
type, kept for pre-SDK consumers. Do not build on it.
- Name
livemode- Type
- boolean
- Description
falsefor deliveries from a sandbox subscription. Branch on this to keep test traffic out of production records.
- Name
created_at- Type
- string
- Description
- ISO 8601 timestamp of when the event was generated.
- Name
data- Type
- object
- Description
- The per-event payload — see Event payloads.
Requests also carry MGX-Event (the event type) and MGX-Signature (see Verifying webhooks).
Delivery body
{
"id": "evt_kk3nqxTGeGnBGmA2Vrqs1J7c",
"type": "bid.accepted",
"event": "bid.accepted",
"livemode": true,
"created_at": "2026-07-16T15:04:00Z",
"data": {
"bid_id": "bid_7Qp2",
"reference": "BID-26G-0104",
"inventory_id": "inv_9XkP",
"status": "accepted",
"type": "inventory"
}
}
Retries and failure handling
Answer every delivery with a 2xx as soon as you have durably accepted it. Do the work afterwards — the delivery times out after 10 seconds.
- Name
2xx- Type
- acknowledged
- Description
- The delivery is complete. Nothing is retried.
- Name
4xx- Type
- permanent
- Description
- The consumer understood the request and refused it, so a byte-identical retry would be refused again. Not retried, apart from
408and429— see below.
- Name
5xx, 408, 429- Type
- retryable
- Description
- A transient condition on the consumer side. Retried on the schedule below.
- Name
timeout / connection error- Type
- retryable
- Description
- No response within 10s, connection refused, or DNS failure. Retried on the schedule below.
408 Request Timeout and 429 Too Many Requests are retried even though they are 4xx, because both explicitly invite a later attempt. Every other 4xx — including the 400 you should return on a failed signature check — is treated as permanent and is not retried.
Retryable failures are attempted 5 times in total, with backoff between attempts:
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | — (immediate) |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
Once the schedule is exhausted the delivery is marked failed and is not retried automatically. It stays listed under deliveries and can be replayed at any time.
Because retries and replays reuse the envelope id, a consumer that acked after its response was lost will receive the same id again — which is why deduplication on id is required rather than optional.
Event payloads
Payloads are deliberately thin: data carries opaque public ids plus a status, not the full resource. Treat the event as a signal, verify it, then fetch the current state from the API with the id — that way a delayed or out-of-order delivery can never overwrite newer state.
bid.accepted / bid.rejected / bid.countered / bid.received
All four bid events share one shape. status is the bid's status at send time: pending, accepted, rejected, countered, cancelled, or expired. Fetch the full bid with GET /v1/bids/:id.
data
{
"bid_id": "bid_7Qp2",
"reference": "BID-26G-0104",
"inventory_id": "inv_9XkP",
"status": "countered",
"type": "inventory"
}
trade.created
Fires alongside bid.accepted when an accepted bid becomes a trade, and is delivered to both the buying team and the seller's teams. bid_id links back to the bid that produced the trade. For side and (after settlement) the counterparty, fetch GET /v1/trades/:id.
data
{
"trade_id": "trd_3Fa8",
"bid_id": "bid_7Qp2",
"status": "Pending",
"inventory_id": "inv_9XkP",
"commodity": { "slug": "cwrs-wheat", "name": "CWRS Wheat" },
"quantity_mt": 120.00,
"price": { "amount": 312.50, "currency": "CAD", "unit": "MT" }
}
trade.settled
Fires once both invoices on the trade are paid, and is delivered to both the buying team and the seller's teams. Same shape as trade.created minus bid_id. The counterparty is unmasked from this point — fetch GET /v1/trades/:id to read it.
data
{
"trade_id": "trd_3Fa8",
"status": "Completed",
"inventory_id": "inv_9XkP",
"commodity": { "slug": "cwrs-wheat", "name": "CWRS Wheat" },
"quantity_mt": 120.00,
"price": { "amount": 312.50, "currency": "CAD", "unit": "MT" }
}
cashbid.offer_received
A seller offered grain against one of your posted cash bids. Fetch offers with GET /v1/cash-bids/:id/offers.
data
{
"offer_id": "cbo_5Wd1",
"cash_bid_id": "cb_2Hn6",
"status": "pending"
}
analysis.*
All analysis events include the inventory's public id and human reference. Each stage adds its own fields: analysis.requested includes the requested types and billing; analysis.graded adds the final grade; analysis.photo_added adds the approved photo_url; analysis.sample_received carries the base fields only.
analysis.requested
{
"inventory_id": "inv_9XkP",
"reference": "MGX-020391",
"analysis_types": ["Falling Number", "Protein"],
"invoice_total": 85.00,
"billed_to": "partner"
}
analysis.graded
{
"inventory_id": "inv_9XkP",
"reference": "MGX-020391",
"grade": "No. 1 CWRS"
}
analysis.photo_added
{
"inventory_id": "inv_9XkP",
"reference": "MGX-020391",
"photo_url": "https://files.mygrainexchange.com/analysis/images/20391/MGX-020391-1752682800.jpg"
}
Verifying webhooks
Every delivery carries an MGX-Signature header of the form t=<unix>,v1=<hex hmac>, where the HMAC is SHA-256 over the string "{t}.{rawBody}" keyed with the subscription's signing secret — the whsec_... value returned once when the subscription is created. Store it then; MGX cannot show it again.
Verifying without an SDK is four steps:
- Split the header on
,and read thet=andv1=values. - Reject the delivery if
tis more than 300 seconds from your own clock — that window is what blocks replay attacks. - Compute
HMAC-SHA256(key = whsec_..., message = "{t}." + rawBody)and hex-encode it. - Compare it to
v1in constant time, and reject on mismatch.
MGX-Signature: t=1768575840,v1=5f3a…c21b,sha256=9be1…07d4
The header may also contain a legacy sha256=<hex hmac(rawBody)> segment — the pre-SDK scheme, which omits the timestamp and therefore offers no replay protection. It is retained only for consumers written before v1 existed. Verify v1 and ignore sha256; parse the header by key rather than by position, since the segment order is not guaranteed.
The SDKs ship a verify() that recomputes the HMAC and compares it in constant time, rejects timestamps outside a tolerance window (300s by default) to block replays, and returns a typed WebhookEvent ({ id, type, created_at, data }). Pass the exact raw request body — not a re-serialized object — or the signature will not match. On any failure it throws, so reject the delivery with a 400 and never act on an unverified payload.
Verify an inbound webhook
// e.g. Express — read the RAW body with express.raw({ type: 'application/json' }).
app.post('/mgx/hook', (req, res) => {
const rawBody = req.body.toString('utf8')
const signature = req.header('MGX-Signature') ?? ''
try {
const event = mgx.webhooks.verify(rawBody, signature, process.env.MGX_WEBHOOK_SECRET!)
switch (event.type) {
case 'trade.created':
console.log('new trade', event.data)
break
case 'cashbid.offer_received':
console.log('offer received', event.data)
break
default:
console.log('unhandled', event.type)
}
res.sendStatus(200)
} catch {
// Bad signature or stale timestamp — do not trust the payload.
res.sendStatus(400)
}
})
The verified WebhookEvent looks like this:
{
"id": "evt_kk3nqxTGeGnBGmA2Vrqs1J7c",
"type": "bid.accepted",
"livemode": true,
"created_at": "2026-07-16T15:04:00Z",
"data": { "bid_id": "bid_7Qp2", "reference": "BID-26G-0104", "status": "accepted" }
}
Testing in sandbox
Trigger a real event against your own sandbox data without waiting for a counterparty. The simulator does not post a fabricated payload — it creates the actual object behind the event. For bid.received, a reusable simulator buyer (a sandbox test user provisioned on first use) places a genuine pending bid on one of your sandbox listings, and the full production chain fires: the bid.received webhook is signed and delivered to your sandbox subscription, and the bid is then fetchable via GET /v1/bids/:id and can be accepted, rejected, or countered — so you can exercise your entire receive → act flow end-to-end.
Requires a sandbox token. Drafts (unlisted listings) are valid targets. Re-triggering against the same listing expires the previous simulated bid and places a fresh one.
Body
- Name
event- Type
- string
- Description
- Required. Currently bid.received. For other events, drive the real flow with sandbox test users instead.
- Name
inventory_id- Type
- string
- Description
- One of your sandbox listing ids. Defaults to your most recently created sandbox listing.
- Name
price- Type
- number
- Description
- Simulated bid price per MT. Defaults to the listing's target price (or 250.00).
- Name
quantity- Type
- number
- Description
- Simulated bid quantity in MT, clamped to the listing quantity. Defaults to the full quantity.
webhook_deliveries_queued in the response tells you immediately whether anything will arrive, and warning says why when it is 0. The usual cause is a subscription registered with a live key: mode is fixed at creation, so a livemode subscription never receives sandbox events. Register the subscription with your sandbox token.
Listings owned by a sandbox test user are covered too. A test user owns no OAuth client of its own, so in sandbox mode events for the parties you provisioned are routed to the subscriptions of the client that created them — you receive bid.received whether the listing belongs to your own sandbox token or to a test seller you spun up.
The alternative — provisioning a buyer test user via POST /v1/sandbox/users and placing a bid with its token — still works and remains the way to simulate flows the simulator does not cover yet. The simulator is simply that flow, automated and gate-free (it can target drafts).
Request
curl https://api.mygrainexchange.com/v1/sandbox/simulate-event \
-H "Authorization: Bearer $MGX_SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "bid.received",
"inventory_id": "inv_9XkP"
}'
Response
{
"data": {
"event": "bid.received",
"simulated": true,
"bid_id": "bid_7Qp2",
"bid_reference": "BID-26G-0104",
"inventory_id": "inv_9XkP",
"buyer": {
"id": "1042",
"email": "sandbox+k3v9q2mp@sandbox.mgx.invalid"
},
"webhook_deliveries_queued": 1,
"warning": null
}
}
Register a subscription
The signing secret is returned only once, at creation.
Request
curl https://api.mygrainexchange.com/v1/webhooks \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{ "url": "https://erp.example.com/mgx/hook", "events": ["bid.accepted", "trade.settled"] }'
Response
{
"data": {
"id": "whk_4Tz9",
"url": "https://erp.example.com/mgx/hook",
"events": ["bid.accepted", "trade.settled"],
"secret": "whsec_8fK2...shown_once"
}
}
List subscriptions
All webhook subscriptions for your team.
Request
curl https://api.mygrainexchange.com/v1/webhooks \
-H "Authorization: Bearer {token}"
Delete a subscription
Stop delivering to a subscription.
Request
curl -X DELETE https://api.mygrainexchange.com/v1/webhooks/whk_4Tz9 \
-H "Authorization: Bearer {token}"
Inspect deliveries
Delivery attempts for a subscription, newest first. One row per attempt — attempts of the same event share an event_id, which is the id your endpoint received in the envelope.
Query parameters
- Name
status- Type
- string
- Description
- Only attempts in this state:
delivered,retrying, orfailed. Passfailedfor the dead-letter view.
- Name
limit- Type
- integer
- Description
- 1-50. Defaults to 20.
- Name
offset- Type
- integer
- Description
- Defaults to 0.
Status values
- Name
delivered- Type
- terminal
- Description
- Acked with a 2xx.
- Name
retrying- Type
- in progress
- Description
- Another attempt is scheduled.
- Name
failed- Type
- terminal
- Description
- Will not be retried on its own — the consumer refused it, or the retry schedule ran out. These are the rows to replay.
Request
curl "https://api.mygrainexchange.com/v1/webhooks/whk_4Tz9/deliveries?status=failed" \
-H "Authorization: Bearer {token}"
Response
{
"items": [
{
"id": "whd_2Bv9",
"event": "trade.created",
"event_id": "evt_kk3nqxTGeGnBGmA2Vrqs1J7c",
"status": "failed",
"response_status": 500,
"attempts": 5,
"delivered_at": null,
"created_at": "2026-07-16T15:34:00Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}
Replay a delivery
Queue the same event to the same endpoint again. This is the recovery path after a consumer outage: list deliveries with ?status=failed, then replay each one.
The replay reuses the original envelope id, so a consumer that already processed the event can dedup it rather than apply it twice. The replay is itself retried on the normal schedule and appears as a new row in the deliveries list.
Requires the webhooks.write scope. Returns 422 if the subscription is inactive, since nothing would be delivered.
Request
curl -X POST \
https://api.mygrainexchange.com/v1/webhooks/whk_4Tz9/deliveries/whd_2Bv9/redeliver \
-H "Authorization: Bearer {token}"
Response
{
"data": {
"queued": true,
"event": "trade.created",
"event_id": "evt_kk3nqxTGeGnBGmA2Vrqs1J7c"
}
}