MeyktAPI Documentation
OpenAPI specllms-full.txtDashboard
  • Overview
  • Quickstart
  • Authentication
  • Orders
  • Files
  • Webhooks
  • Errors

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.

bash
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"
  }'
json
{
  "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 secret is 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.
  • https only. An http:// URL is rejected with 422 webhook_url_invalid. URLs with embedded credentials (https://user:pass@…) are rejected the same way.
  • Private and reserved addresses are rejected with 422 webhook_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/302 response counts as a failed delivery. Point the endpoint at its final URL.
  • At most 5 active endpoints per connection. A sixth registration returns 422 webhook_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.

bash
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 typeSent whenAdditional data fields
order.receivedAn API order was accepted and newly created (HTTP 201)—
order.production_startedThe production order moved to in_progressproduction_order_id, production_status
order.production_completedThe production order moved to completedproduction_order_id, production_status
order.production_failedThe production order moved to failedproduction_order_id, production_status
order.cancelledThe production order was cancelledproduction_order_id; plus production_status when it came from a status transition
order_item.completedA production job for one order item was completedorder_item_id, production_job_id, sku, name
pingYou called the test endpointmessage only — no order fields

Scope and limits of the event stream:

  • There is no shipped event. Meykt has no shipping workflow, so there is nothing to report. Do not build a flow that waits for one. Use order.production_completed (whole order) or order_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.received fires only for newly created orders. A repeated POST /v1/orders with identical content answers 200 with 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 type as "ignore" rather than as an error.

#Payload

Every delivery is a POST with Content-Type: application/json and this envelope:

json
{
  "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 the X-Meykt-Event-Id header.
  • type — one of the values in Event types. Also sent as X-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 — contains order_id and external_order_id for every order event, plus the event-specific fields from the table. ping is 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:

http
X-Meykt-Signature: t=1786633442,v1=6b2f9c1e84a70d5f3e2c9b81a4d70f6e5c3b2a19d8e7f6c5b4a39281706f5e4d
  • t — Unix timestamp in seconds at the moment of signing.
  • v1 — lowercase hex HMAC-SHA256 over the string "{t}.{raw request body}", keyed with your whsec_… secret.

Three rules, all mandatory:

  1. 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.
  2. Enforce a tolerance window. Reject the request if t deviates from your own clock by more than ±5 minutes (300 seconds). This blocks replay of an old, valid payload.
  3. 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

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

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

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 the X-Meykt-Event-Id header) 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 2xx within 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 200 is 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: false with disabled_reason: "too_many_failures". A successful delivery resets the counter to zero. Once disabled, the endpoint receives nothing until you register a new one — monitor consecutive_failures through GET /v1/webhook-endpoints or 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.

bash
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.

ParameterMeaning
endpoint_idRestrict to one endpoint. An id that is not yours returns 404 not_found.
limit1–100, default 25.
bash
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:

bash
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

On this page

  • Register an endpoint
  • Listing and disabling endpoints
  • Event types
  • Payload
  • Verifying the signature
  • Node.js
  • PHP
  • Python
  • Orders produced by a partner
  • Delivery guarantees
  • Testing
  • Delivery log
  • No public server?

Base URL https://api.meykt.com/v1 · Machine-readable: openapi.json · llms-full.txt

Imprint · Privacy · Terms