Outbound webhooks
Verified
In short
Section titled “In short”A webhook is the opposite of REST and MCP: you do not ask, MitoOps calls you. When an event you subscribe to occurs in your account, we POST a short signed envelope to your address. The envelope carries identifiers and statuses, not the content of the order — your system reads the detail over the REST interface with its own key.
Subscriptions are managed in the application: Settings → API & MCP → Outbound webhooks. They are available from the Start plan; the Outbound webhooks permission (read or manage) is required.
Subscription (endpoint)
Section titled “Subscription (endpoint)”An endpoint has a name, an address, a list of events and a store scope. On creation you receive a secret — shown once, never again; you can rotate it at any time and the old one stops working immediately.
- The address must be public and use
https://on the standard port. Internal-network addresses (the local machine, private ranges, cloud metadata, VPN ranges) are rejected on save and on every delivery — the name is resolved again and the connection is pinned to the verified addresses. - Redirects are not followed. A
3xxresponse is a failure. - Stores: all (including ones added later) or selected. An event from a store outside the scope is not sent.
- A test delivery from the button sends a
pingevent along the same path as a real one — including the signature and the log entry. - An endpoint can be disabled (pending deliveries are skipped) and deleted (the log remains).
Events
Section titled “Events”| Event | When | resource.type |
Fields in data |
|---|---|---|---|
order.created |
new order from the store | order |
order_code |
order.paid |
order marked as paid | order |
order_code |
order.status_changed |
order status changed | order |
order_code, status_id, previous_status_id |
shipment.created |
shipment created with the carrier | shipment |
shipment_id, order_code, carrier_code, tracking_number |
shipment.status_changed |
shipment status changed (carrier or staff) | shipment |
shipment_id, order_code, carrier_code, status, previous_status, tracking_number |
shipment.delivered |
shipment delivered; in addition to shipment.status_changed |
shipment |
as above, status is DELIVERED |
inventory.stock_changed |
stock movement of an item | inventory_item |
item_id, sku, kind |
invoice.created |
invoice issued | invoice |
invoice_code, order_code |
claim.created |
claim opened (by the customer or by staff) | claim |
claim_code, order_code |
claim.status_changed |
claim status changed | claim |
claim_code, order_code, status_id |
claim.closed |
claim closed | claim |
claim_code, order_code |
ping |
test delivery from the button | webhook_endpoint |
message, endpoint |
Events come from the same business layer as automations: whatever triggers an automation can also be subscribed to as a webhook. Return shipments are not sent as shipment.* — they belong to the claim.
Envelope
Section titled “Envelope”Every delivery is JSON with an envelope version. A new field may be added without changing the version; a change in a field’s meaning is a new version.
{ "event_id": "9b2f0c1e-6d3a-4c7e-9a2b-0f1e2d3c4b5a", "event_type": "shipment.delivered", "version": 1, "occurred_at": "2026-09-03T08:41:12.318Z", "tenant": "00001", "store": "sk", "resource": { "type": "shipment", "id": "5521" }, "data": { "shipment_id": "5521", "order_code": "2026001234", "carrier_code": "gls", "status": "DELIVERED", "previous_status": "IN_TRANSIT", "tracking_number": "GLS123456789" }}event_idis unique per event. If two of your endpoints subscribe to the same event, they receive the same ID. A retry carries the same ID and the same body.storeis the store code (market) — the same one REST uses.tenantis the identifier of your MitoOps account.
Headers and signature
Section titled “Headers and signature”Content-Type: application/jsonUser-Agent: MitoOps-Webhooks/1X-MitoOps-Event: shipment.deliveredX-MitoOps-Event-Id: 9b2f0c1e-6d3a-4c7e-9a2b-0f1e2d3c4b5aX-MitoOps-Timestamp: 1756888872X-MitoOps-Signature: v1=3f1a…c9e0X-MitoOps-Delivery: 184The signature is HMAC-SHA256 keyed with the endpoint secret over the string:
<X-MitoOps-Timestamp> + "." + <exact request body>The result is hexadecimal and appears in the header with the version prefix v1=. The timestamp is Unix time in seconds.
Verifying on your side
Section titled “Verifying on your side”Verify over the raw request body (the bytes as received), not over re-serialised JSON — any change in whitespace or key order invalidates the signature. Compare in constant time and reject messages older than 5 minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyMitoOpsWebhook({ secret, headers, rawBody, now = Math.floor(Date.now() / 1000) }) { const ts = Number(headers['x-mitoops-timestamp']); if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) return false; // old or missing timestamp
const received = String(headers['x-mitoops-signature'] || '') .split(',').map((s) => s.trim()).find((s) => s.startsWith('v1=')); if (!received) return false;
const expected = createHmac('sha256', secret) .update(String(ts) + '.').update(rawBody).digest('hex'); const a = Buffer.from(received.slice(3), 'hex'); const b = Buffer.from(expected, 'hex'); return a.length === b.length && timingSafeEqual(a, b);}import hmac, hashlib, time
def verify_mitoops_webhook(secret: str, headers: dict, raw_body: bytes) -> bool: try: ts = int(headers["X-MitoOps-Timestamp"]) except (KeyError, ValueError): return False if abs(int(time.time()) - ts) > 300: return False sig = next((s.strip() for s in headers.get("X-MitoOps-Signature", "").split(",") if s.strip().startswith("v1=")), None) if not sig: return False expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(sig[3:], expected)Replay protection
Section titled “Replay protection”The signature covers the timestamp, so a captured message cannot be re-sent later with a fresh time. Alongside the time check, keep the event_id of recent deliveries and ignore duplicates: a repeated delivery is a property of the system, not an error.
Response, retries and the log
Section titled “Response, retries and the log”- Respond with
2xxwithin 10 seconds. The response body is not read. Queue any longer processing in the background and respond immediately. - Any other response (
4xx,5xx), a timeout or a connection error is a failure and is retried with back-offs of 1, 5, 15, 60 and 240 minutes; six attempts in total. After that the delivery isFAILEDand waits for a manual retry from the log. - A
3xxresponse and an address that no longer resolves to a public network are failures without retry — they do not fix themselves. - The delivery log in the application shows the status, HTTP code, time, attempt count and event for every attempt. The message body is not shown in the log.
| Status | Meaning |
|---|---|
PENDING |
waiting for an attempt or for the next retry |
SENT |
the receiver responded 2xx |
FAILED |
attempts exhausted or a permanent error; can be retried manually |
SKIPPED |
the endpoint was disabled or deleted in the meantime |
Security
Section titled “Security”- The secret is stored encrypted and is never written to logs or to the delivery log. It is shown once; rotation works even when the plan no longer includes webhooks — it is a security operation.
- No personal data. The envelope carries identifiers and statuses. The customer’s name, address and e-mail are not sent; whoever needs them reads them over REST with their own scope and audit trail.
- HTTPS only, public addresses only, no redirects. The check on save and on every delivery protects against routing into an internal network and against DNS changes.
- A subscription is created by a person, not by a key. Neither a REST access key nor an OAuth consent can create a subscription: reading on request and a permanent outbound stream of events are two different permissions.
- Isolation. Subscriptions and the log live in your account’s space; the store scope is evaluated on every event.
| Plan | Outbound webhooks | Endpoints |
|---|---|---|
| Trial | — | 0 |
| Beginner | — | 0 |
| Start | yes | 5 |
| Growth | yes | 20 |
| Pro | yes | 50 |
| Custom | by agreement | by agreement |
Non-deleted endpoints count, enabled or disabled. Downgrading does not delete existing endpoints; no new one can be added until the count drops below the limit, and on a plan without webhooks events stop being sent until the plan is restored.
Webhook from an automation
Section titled “Webhook from an automation”An endpoint can also be the target of the Send webhook step in Automations: the message is sent only once the rule’s conditions are met, not on every event. The step only picks the endpoint — the address, secret and store scope stay in the registry.
The message has type automation.action (it cannot be subscribed to directly) and the same envelope, signature, retries and log as other events. In data it carries automation.workflow_id, automation.run_id, automation.node_id, automation.source_event (the event that woke the rule) and the identifiers of the order, shipment or claim; resource is the run’s entity. Load details through the REST API.
Automatic pause
Section titled “Automatic pause”If the receiver fails 30 attempts in a row or an endpoint has more than 5,000 pending deliveries, MitoOps pauses the endpoint and notifies you. Pending deliveries stay; fix the receiver, enable the endpoint and they resume. The delivery log is kept for 30 days.