# 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`](/en/developers/errors/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`](/en/developers/errors/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`](/en/developers/errors/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 `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](/en/developers/webhooks#delivery-guarantees)). It is also sent as the `X-Meykt-Event-Id` header.
- `type` — one of the values in [Event types](/en/developers/webhooks#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`](/en/developers/errors/validation_failed); an unknown or foreign id returns `404` [`not_found`](/en/developers/errors/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. |

```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](/en/developers/orders).
