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.

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 id as 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
    false for 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 408 and 429 — 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.

Retryable failures are attempted 5 times in total, with backoff between attempts:

AttemptDelay after the previous attempt
1— (immediate)
210 seconds
31 minute
45 minutes
530 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:

  1. Split the header on , and read the t= and v1= values.
  2. Reject the delivery if t is more than 300 seconds from your own clock — that window is what blocks replay attacks.
  3. Compute HMAC-SHA256(key = whsec_..., message = "{t}." + rawBody) and hex-encode it.
  4. Compare it to v1 in constant time, and reject on mismatch.
MGX-Signature: t=1768575840,v1=5f3a…c21b,sha256=9be1…07d4

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" }
}

POST/v1/sandbox/simulate-event

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.

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

POST
/v1/sandbox/simulate-event
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
  }
}

POST/v1/webhooks

Register a subscription

The signing secret is returned only once, at creation.

Request

POST
/v1/webhooks
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"
  }
}

GET/v1/webhooks

List subscriptions

All webhook subscriptions for your team.

Request

GET
/v1/webhooks
curl https://api.mygrainexchange.com/v1/webhooks \
  -H "Authorization: Bearer {token}"

DELETE/v1/webhooks/:id

Delete a subscription

Stop delivering to a subscription.

Request

DELETE
/v1/webhooks/whk_4Tz9
curl -X DELETE https://api.mygrainexchange.com/v1/webhooks/whk_4Tz9 \
  -H "Authorization: Bearer {token}"

GET/v1/webhooks/:id/deliveries

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, or failed. Pass failed for 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

GET
/v1/webhooks/whk_4Tz9/deliveries
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
}

POST/v1/webhooks/:id/deliveries/:deliveryId/redeliver

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

POST
/v1/webhooks/whk_4Tz9/deliveries/whd_2Bv9/redeliver
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"
  }
}

Was this page helpful?