Webhooks
Webhooks push order and production events from Meykt to an HTTPS endpoint you control, so you do not have to poll. Endpoints are managed through the API with an API key that carries the webhooks:manage scope, and every delivery is signed with an HMAC-SHA256 signature you must verify. Events cover only API orders in the live environment — never test orders, never orders that came from a connected shop.
#Register an endpoint
POST https://api.meykt.com/v1/webhook-endpoints creates an endpoint. Requires the webhooks:manage scope.
curl -X POST https://api.meykt.com/v1/webhook-endpoints \
-H "Authorization: Bearer $MEYKT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.musterwerkstatt.example/meykt/webhook",
"description": "Musterwerkstatt ERP"
}'{
"data": {
"endpoint": {
"id": "b7f1c2a4-9d3e-4c1b-8a52-0f6d9e4a7c31",
"url": "https://erp.musterwerkstatt.example/meykt/webhook",
"description": "Musterwerkstatt ERP",
"is_active": true,
"disabled_reason": null,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"created_at": "2026-08-07T09:12:44.118Z"
},
"secret": "whsec_3kQpZ8vN2yLdR7fWbXs1TgCmHjA4uE0i"
}
}Rules that apply at registration time:
- The
secretis returned exactly once, in this response. It cannot be retrieved later — not through the API, not through the dashboard. Store it in your configuration before you do anything else. If you lose it, delete the endpoint and register a new one. httpsonly. Anhttp://URL is rejected with422webhook_url_invalid. URLs with embedded credentials (https://user:pass@…) are rejected the same way.- Private and reserved addresses are rejected with
422webhook_target_blocked. The hostname is resolved when the endpoint is created and the resolved address is pinned; deliveries connect to that pinned address. - Redirects are not followed. A
301/302response counts as a failed delivery. Point the endpoint at its final URL. - At most 5 active endpoints per connection. A sixth registration returns
422webhook_limit_reached. Disable one first.
#Listing and disabling endpoints
GET https://api.meykt.com/v1/webhook-endpoints returns your endpoints (up to 50, newest first). Secrets are never included.
curl https://api.meykt.com/v1/webhook-endpoints \
-H "Authorization: Bearer $MEYKT_API_KEY"Each entry carries id, url, description, is_active, disabled_reason, consecutive_failures, last_success_at, last_failure_at and created_at. Use consecutive_failures and disabled_reason for health monitoring.
DELETE https://api.meykt.com/v1/webhook-endpoints/{id} disables an endpoint permanently. It is a deactivation, not a row deletion: the delivery log stays readable. The response is {"data": {"id": "...", "is_active": false}}. An unknown or foreign id returns 404 not_found — never 403.
#Event types
| Event type | Sent when | Additional data fields |
|---|---|---|
order.received | An API order was accepted and newly created (HTTP 201) | — |
order.production_started | The production order moved to in_progress | production_order_id, production_status |
order.production_completed | The production order moved to completed | production_order_id, production_status |
order.production_failed | The production order moved to failed | production_order_id, production_status |
order.cancelled | The production order was cancelled | production_order_id; plus production_status when it came from a status transition |
order_item.completed | A production job for one order item was completed | order_item_id, production_job_id, sku, name |
ping | You called the test endpoint | message only — no order fields |
Scope and limits of the event stream:
- There is no
shippedevent. Meykt has no shipping workflow, so there is nothing to report. Do not build a flow that waits for one. Useorder.production_completed(whole order) ororder_item.completed(single item) as your "work is done" signal. - Live API orders only. Test orders (keys with the
mk_test_prefix) and orders that arrived through a shop connection never produce events. order.receivedfires only for newly created orders. A repeatedPOST /v1/orderswith identical content answers200with the existing order and produces no event. That is intentional: the retry is a no-op, so there is nothing new to report.- Status transitions only. If a production order is set to a status it already has, no event is produced.
- Event type names are a permanent contract. New types may be added; existing ones are never renamed or repurposed. Treat an unknown
typeas "ignore" rather than as an error.
#Payload
Every delivery is a POST with Content-Type: application/json and this envelope:
{
"id": "1f4d2a67-5b83-4e0c-9a11-8c7de2b45f90",
"type": "order.production_completed",
"created_at": "2026-08-07T11:04:02.517Z",
"api_version": "v1",
"data": {
"order_id": "9c2b7d18-3e54-42a7-b6f0-5a1d8e93c204",
"external_order_id": "ORDER-2026-1044",
"production_order_id": "44a0f6c9-71b2-4d38-9e5c-2f8b0a7e1d36",
"production_status": "completed"
}
}id— unique per event. Use it for deduplication (see Delivery guarantees). It is also sent as theX-Meykt-Event-Idheader.type— one of the values in Event types. Also sent asX-Meykt-Event-Type.created_at— ISO 8601 UTC timestamp of when the event was recorded, not of the delivery attempt.api_version— always"v1"for this API version.data— containsorder_idandexternal_order_idfor every order event, plus the event-specific fields from the table.pingis the only type without order fields.
The data object may gain new fields at any time. Ignore fields you do not know; never reject a payload because it contains more than you expected.
Additional request headers on every delivery: X-Meykt-Signature, X-Meykt-Event-Id, X-Meykt-Event-Type, User-Agent: Meykt-Webhooks/1.0.
#Verifying the signature
Every delivery carries a signature header:
X-Meykt-Signature: t=1786633442,v1=6b2f9c1e84a70d5f3e2c9b81a4d70f6e5c3b2a19d8e7f6c5b4a39281706f5e4dt— Unix timestamp in seconds at the moment of signing.v1— lowercase hex HMAC-SHA256 over the string"{t}.{raw request body}", keyed with yourwhsec_…secret.
Three rules, all mandatory:
- Sign the raw body bytes. Do not parse the JSON and re-serialize it — key order and whitespace would change and the signature would never match. Read the request body as a raw string or byte buffer before any JSON middleware touches it.
- Enforce a tolerance window. Reject the request if
tdeviates from your own clock by more than ±5 minutes (300 seconds). This blocks replay of an old, valid payload. - Compare in constant time. Use
timingSafeEqual/hash_equals/hmac.compare_digest, never==on the hex strings.
Reject with 401 and process nothing if any of the three checks fails.
#Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyMeyktSignature(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
const want = Buffer.from(expected, 'hex');
return given.length === want.length && timingSafeEqual(given, want);
}In Express, mount express.raw({ type: 'application/json' }) on the webhook route so req.body is the untouched buffer.
#PHP
function meykt_verify_signature(
string $header,
string $rawBody,
string $secret,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
$kv = explode('=', $piece, 2);
if (count($kv) === 2) {
$parts[trim($kv[0])] = trim($kv[1]);
}
}
if (!isset($parts['t'], $parts['v1'])) return false;
$t = (int) $parts['t'];
if (abs(time() - $t) > $toleranceSeconds) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Usage
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_MEYKT_SIGNATURE'] ?? '';
if (!meykt_verify_signature($header, $rawBody, getenv('MEYKT_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);#Python
import hashlib
import hmac
import time
def verify_meykt_signature(header: str, raw_body: bytes, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{t}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, given)In Flask use request.get_data(); in Django use request.body. Both give you the raw bytes.
#Orders produced by a partner
Some Meykt organizations do not produce every order themselves — they pass items to a partner workshop. When that happens, you still receive the same order-level events (order.production_started, order.production_completed, order.production_failed, order.cancelled) against your own order, and updated_at on your order changes so polling picks it up too. The event carries "produced_by_partner": true in its data object.
One difference: order_item.completed is not emitted for partner-produced orders. Your item IDs do not exist in the partner's system, so an item-level event could not name an item you can look up. Use the order-level events instead.
#Delivery guarantees
Delivery is at least once. An event can arrive more than once — after a network timeout on our side, after a retry, or after a manual redelivery.
- Deduplication is your responsibility. Store the event
id(or theX-Meykt-Event-Idheader) and discard anything you have already processed. Do not rely on event order either: retries can make a later event arrive before an earlier one. - Respond
2xxwithin 10 seconds. Anything else —3xx,4xx,5xx, a connection error, or a timeout — counts as a failed attempt. Acknowledge first, do the heavy work afterwards in your own queue. - The response body is ignored. Only the status code matters. An empty
200is a perfectly good answer. - Retry schedule after a failed attempt: 1 min, 5 min, 15 min, then 1 h, 3 h, 6 h, 12 h, 24 h. After the last attempt the delivery is marked permanently failed and is not retried again. The full window spans roughly 48 hours.
- Automatic shutdown after 25 consecutive failures. The endpoint is set to
is_active: falsewithdisabled_reason: "too_many_failures". A successful delivery resets the counter to zero. Once disabled, the endpoint receives nothing until you register a new one — monitorconsecutive_failuresthroughGET /v1/webhook-endpointsor the dashboard card.
#Testing
POST https://api.meykt.com/v1/webhook-endpoints/{id}/test queues a ping event to that one endpoint and wakes the delivery worker immediately. Use it to prove that your signature check and your server work — no real order required.
curl -X POST https://api.meykt.com/v1/webhook-endpoints/b7f1c2a4-9d3e-4c1b-8a52-0f6d9e4a7c31/test \
-H "Authorization: Bearer $MEYKT_API_KEY"The API answers 202 with {"data": {"queued": true}}; the delivery itself arrives within seconds. The payload uses the normal envelope with "type": "ping" and a data object containing only a message.
A disabled endpoint returns 422 validation_failed; an unknown or foreign id returns 404 not_found.
#Delivery log
GET https://api.meykt.com/v1/webhook-deliveries returns the recent delivery attempts for your endpoints, newest first.
| Parameter | Meaning |
|---|---|
endpoint_id | Restrict to one endpoint. An id that is not yours returns 404 not_found. |
limit | 1–100, default 25. |
curl "https://api.meykt.com/v1/webhook-deliveries?limit=10" \
-H "Authorization: Bearer $MEYKT_API_KEY"Each entry contains id, endpoint_id, event_id, event_type, status, attempts, next_attempt_at, last_attempt_at, response_status, response_snippet, error_code and created_at. The request body is not returned — you already know the contract; the log exists to explain *what happened to the delivery*. response_snippet holds the beginning of your server's response and is usually the fastest way to find a misconfigured receiver.
The same information is available in the dashboard under Integrations → API access → Webhooks, together with a "Redeliver" action for individual deliveries.
#No public server?
Webhooks require a publicly reachable HTTPS endpoint. If you do not have one — an on-premise ERP behind a firewall, a workshop PC, a scheduled script — polling is a fully supported path, not a workaround:
curl "https://api.meykt.com/v1/orders?updated_since=2026-08-07T10:00:00Z&limit=100" \
-H "Authorization: Bearer $MEYKT_API_KEY"Poll GET /v1/orders with updated_since on your own schedule and compare the returned status against what you last stored. Overlap the window slightly (for example, ask for the last 10 minutes every 5 minutes) so a clock skew cannot skip a change. Keep the rate limit of 120 requests per minute per key in mind: one list call with updated_since is always cheaper than one call per order. Details on paging and the updated_since parameter are on the orders page.
Machine-readable version of this page: https://www.meykt.com/developers/webhooks.md