Webhooks
Webhooks are how Shop.mn and PickPack notify each other of state changes. Both sides expose a receiver URL that accepts signed POST requests carrying a JSON event envelope.
Delivery model#
Every state change emits exactly one event. Events are delivered via HTTPS POST to the receiver URL registered during onboarding. The receiver must respond with HTTP 2xx within 10 seconds; any other response (or a timeout) triggers a retry.
Retries use exponential backoff over 24 hours: 1m, 5m, 15m, 1h, 6h, 24h. After the final attempt the event is moved to a dead-letter queue and an alert is raised internally.
Event envelope#
All events share the same outer shape:
{
"id": "evt_...",
"type": "shipment.picked_up",
"created_at": "2026-05-26T09:14:22Z",
"data": { /* event-specific payload */ }
}The id is unique per event and stable across retries — use it for de-duplication on the receiver side.
Event types#
| Type | Description |
|---|---|
| shipment.created | Emitted by Shop.mn when a new shipment is registered (the inverse acknowledgement of POST /shipments). |
| shipment.picked_up | Emitted by PickPack when a courier collects the parcel from the vendor. |
| shipment.in_transit | Emitted by PickPack on hub scans and movement events. |
| shipment.out_for_delivery | Emitted by PickPack when the parcel is on its final leg to the customer. |
| shipment.delivered | Terminal — emitted by PickPack on successful delivery to the customer. |
| shipment.failed | Terminal — emitted by PickPack when delivery cannot be completed. |
| shipment.canceled | Terminal — emitted by Shop.mn when the vendor cancels the underlying order before pickup. |
Signature verification#
Every webhook request includes an X-Shop-MN header. The header value is the HMAC-SHA256 of the raw request body, signed with a shared webhook secret, encoded as lowercase hex.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyShopMnSignature(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(expected, "hex")
const b = Buffer.from(header || "", "hex")
return a.length === b.length && timingSafeEqual(a, b)
}Always verify before parsing the body as JSON. Reject requests with an invalid signature with HTTP 401 — never 200, or the dispatcher will treat the event as accepted and stop retrying.
X-Webhook-Signature is not used.