# Webhooks

We push order and offer events to your endpoints over HTTPS, each one HMAC-SHA256 signed.
This guide gives you copy-paste verification code in PHP, Node, and Python, the exact
signature scheme, published test vectors you can check against, and the delivery, retry,
and auto-disable behavior you build around. The same signature scheme and verifier are used
for our declared-stock and top-up calls to you, so this is the one page to get right.

## Verify first — PHP

Compute `HMAC-SHA256(secret, "{timestamp}.{raw body}")` over the **raw** request bytes and
compare in constant time. This is the canonical verifier:

```php
function verify_ck_signature(
    string $secret, string $signatureHeader, string $timestampHeader,
    string $rawBody, int $toleranceMs = 300000
): bool {
    // The header is a comma-separated list of scheme=value pairs (e.g.
    // "v1=<hex>,v2=<hex>"). Pick the v1 entry; ignore unknown schemes.
    if (!preg_match('/(?:^|,\s*)v1=([a-f0-9]{64})/', $signatureHeader, $m)) return false;
    if (!ctype_digit($timestampHeader)) return false;
    if (abs((int) round(microtime(true) * 1000) - (int) $timestampHeader) > $toleranceMs) return false;
    $expected = hash_hmac('sha256', $timestampHeader . '.' . $rawBody, $secret);
    return hash_equals($expected, $m[1]);
}

// A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped):
//   verify_ck_signature($secret, "v1={$validHex},v2=deadbeef", $ts, $body) === true
```

## Node

```js
const crypto = require('node:crypto');

function verifyCkSignature(secret, signatureHeader, timestampHeader, rawBody, toleranceMs = 300000) {
  // The header is a comma-separated list of scheme=value pairs (e.g.
  // "v1=<hex>,v2=<hex>"). Pick the v1 entry; ignore unknown schemes.
  const m = /(?:^|,\s*)v1=([a-f0-9]{64})/.exec(signatureHeader ?? '');
  if (!m || !/^\d+$/.test(timestampHeader ?? '')) return false;
  if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceMs) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestampHeader}.${rawBody}`).digest();
  const given = Buffer.from(m[1], 'hex');
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}

// A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped):
//   verifyCkSignature(secret, `v1=${validHex},v2=deadbeef`, ts, body) === true
```

## Python

```python
import hmac, hashlib, re, time

def verify_ck_signature(secret: str, signature_header: str, timestamp_header: str,
                        raw_body: bytes, tolerance_ms: int = 300_000) -> bool:
    # The header is a comma-separated list of scheme=value pairs (e.g.
    # "v1=<hex>,v2=<hex>"). Pick the v1 entry; ignore unknown schemes.
    m = re.search(r"(?:^|,\s*)v1=([a-f0-9]{64})", signature_header or "")
    if not m or not (timestamp_header or "").isdigit():
        return False
    if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_ms:
        return False
    expected = hmac.new(secret.encode(),
                        f"{timestamp_header}.".encode() + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(1))

# A multi-scheme header still verifies on the v1 entry (the garbage v2 is skipped):
#   verify_ck_signature(secret, f"v1={valid_hex},v2=deadbeef", ts, body) is True
```

**Read the raw body straight from the request.** The signature is over the exact bytes
we sent, so keep those bytes intact:

- **PHP:** `file_get_contents('php://input')`; in Laravel, `$request->getContent()`.
- **Node / Express:** use `express.raw({ type: 'application/json' })` on the webhook route
  **before** any JSON body parser runs.
- **Python / Django:** `request.body`.

Sign the bytes as received: re-encoding the parsed JSON reorders keys and changes
whitespace, which produces different bytes and a signature mismatch.

## Events

Subscribe to any subset of these event types when you create or update an endpoint, or use
`["*"]` for everything.

| Type | Fires when |
|---|---|
| `order.created` | An order containing at least one of your items is placed (before payment) |
| `order.expired` | An unpaid order containing at least one of your items expired (checkout abandoned) — a terminal event; the order status is `expired` and its items are `released` |
| `order.paid` | Payment is captured. Under authorize → provision → capture, provisioning happened **before** this event, so declared-stock suppliers see `POST /orders` and item fetches first |
| `order.refunded` | A refund covering at least one of your items is executed |
| `order.disputed` | A buyer claim or card dispute touching one of your items is opened — **informational**; we are the merchant of record |
| `offer.deactivated` | Your offer leaves `active`, whoever caused it (see `reason`) |
| `offer.stock_low` | Uploaded stock crosses `low_stock_threshold` downward |
| `stock.invalidated` | Staff invalidated one of your inventory items |
| `webhook.disabled` | One of your endpoints was auto-disabled |

Test-fires send a **real** event type with the envelope flag `test_fire: true` — the type
is always one of the events above.

Register an endpoint (the secret is shown once — save it):

```bash
curl -X POST "https://sandbox-pay.mmokick.com/api/v1/webhooks" \
  -H "Authorization: Bearer $CK_TEST_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 01JZWWEBHOOKS0000000CREATE1" \
  -d '{
    "url": "https://your-app.example/hooks/cheap-keys",
    "events": ["order.paid", "order.refunded", "order.disputed", "offer.stock_low"],
    "description": "Main ERP intake"
  }'
```

`201`:

```json
{
  "id": "01JZW0DC8FGH2JK4MN6PQ8RS0T",
  "url": "https://your-app.example/hooks/cheap-keys",
  "events": ["order.paid", "order.refunded", "order.disputed", "offer.stock_low"],
  "description": "Main ERP intake",
  "status": "active",
  "secret": "whsec_4d9c2f81a7e35b60d1c8f42e9a0b7365",
  "consecutive_failures": 0,
  "disabled_at": null,
  "created_at": "2026-07-12T10:00:00Z"
}
```

You may register up to **10 endpoints** per environment.

## The envelope

Every delivery is an HTTPS POST with `Content-Type: application/json` and this envelope:

```json
{
  "id": "01JZY3B1M5P7R9T2V4X6Z8A0C2",
  "type": "order.paid",
  "mode": "test",
  "api_version": "v1",
  "test_fire": false,
  "created_at": "2026-07-12T10:03:22Z",
  "data": { }
}
```

`id` is your dedupe key. `mode` is `live` or `test`. `data` reuses the objects from the API
reference — for order events, `data.order` is the supplier-scoped order exactly as
`GET /orders/{id}` returns it.

Delivery expectations:

- **Success** is any `2xx` returned within **10 seconds**. Timeouts and `3xx`/`4xx`/`5xx`
  all fail the attempt.
- **User agent:** `cheap-keys-webhooks/1.0 (+https://pay.mmokick.com/developers)`.
- **Events can arrive in any order.** Consume idempotently: dedupe on envelope `id`. If you
  see `order.refunded` before `order.paid`, trust the embedded `data.order.status`.
- Egress IPs stay unpublished and can change — authenticate deliveries **by signature
  only**, never by source IP.

## Signature scheme

Every delivery carries these headers:

```
X-CK-Timestamp: <unix milliseconds>
X-CK-Signature: v1=<hex(hmac_sha256(secret, "{timestamp}.{raw_body}"))>
X-CK-Event: order.paid
X-CK-Event-Id: 01JZY3B1M5P7R9T2V4X6Z8A0C2
X-CK-Delivery-Id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2
```

The rules, as MUSTs:

- Build the signed payload as `{X-CK-Timestamp value}` + `.` + `{raw request body bytes}`,
  and verify over the **raw body** exactly as received.
- Compare signatures with a **constant-time** comparison (`hash_equals`,
  `crypto.timingSafeEqual`, `hmac.compare_digest`).
- Reject when `|now_ms − timestamp| > 300000` (5 minutes) — this bounds replay.
- The `v1=` prefix versions the scheme. A future scheme ships as `v2=` alongside `v1=`
  (comma-separated), so `v1=` stays in place while you migrate.

Secrets are shown once at creation and are rotatable:

- Webhook endpoint secrets: `whsec_` + 32 lowercase hex.
- Offer fulfilment secrets (declared stock, top-ups): `cksec_` + 32 lowercase hex.

One generation rule, two prefixes. When you rotate a secret, hold both the old and new
secret during rollout and accept either, then retire the old one.

## Published test vectors

These are the official vectors — verify your implementation against them before going live.
`raw_body` is a single line, byte-exact, with no trailing newline. The shared test secret:

```
whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70
```

**Vector 1** — minimal body:

```
timestamp: 1767225600000              # 2026-01-01T00:00:00Z
raw_body:  {"hello":"cheap-keys"}
expected:  v1=8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a
```

**Vector 2** — full event envelope (test-fire of `order.paid`):

```
timestamp: 1767312000000              # 2026-01-02T00:00:00Z
raw_body:  {"id":"01JGY6ZK3M9P4Q6R8S0T2V4W6X","type":"order.paid","mode":"test","api_version":"v1","test_fire":true,"created_at":"2026-01-02T00:00:00Z","data":{"order":{"id":"01JGY6Z9ABCDEF2GHJKMNPQRST","number":"CK-01JGY6Z9ABCDEF2GHJKMNPQRST","status":"completed","currency":"EUR"}}}
expected:  v1=00aa28ad0d3892a33948f3be58aabd8e16f64a6e9c3a55b907e7d71242164ed4
```

Self-check Vector 1 from a shell:

```bash
echo -n "1767225600000.{\"hello\":\"cheap-keys\"}" | \
  openssl dgst -sha256 -hmac "whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70"
```

It prints `8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a`. Passing your
verifier against both vectors means your raw-body handling and HMAC are correct.

## Retries and auto-disable

If an attempt fails (non-`2xx` or timeout), we retry on a fixed ladder:

```
+0, +1 min, +5 min, +30 min, +2 h, +8 h, +24 h    — 7 attempts total
```

After the seventh attempt the event is abandoned for that endpoint; you can still replay it
with `POST /webhook-deliveries/{delivery_id}/resend` for 30 days.

- **Auto-disable:** 30 **consecutive** failed attempts on an endpoint (the counter resets on
  any `2xx`) sets the endpoint to `status: disabled`, fires a `webhook.disabled` event to
  your other active endpoints, and sends a portal alert and email.
- A `410 Gone` response disables the endpoint **immediately** — you are telling us it is
  dead.
- **Re-enable** an auto-disabled endpoint by `PATCH`ing it back to `status: active`, which
  requires a passing `2xx` test-fire first (otherwise `409` / `invalid_state` with the
  failing probe result). Success resets `consecutive_failures` to 0.

## Operations

**Test-fire.** From the portal, or `POST /webhooks/{id}/test`, send a fully signed canned
fixture of any real event type (default `order.paid`) with envelope `test_fire: true`. It
leaves `consecutive_failures` untouched:

```bash
curl -X POST "https://sandbox-pay.mmokick.com/api/v1/webhooks/01JZW0DC8FGH2JK4MN6PQ8RS0T/test" \
  -H "Authorization: Bearer $CK_TEST_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 01JZWWEBHOOKS00000TESTFIRE1" \
  -d '{ "event": "order.paid" }'
```

`200`:

```json
{
  "delivery_id": "01JZY3B2N6Q8S0V2X4Z6B8D0F2",
  "event_type": "order.paid",
  "response_status": 200,
  "duration_ms": 187,
  "succeeded": true
}
```

**Delivery log and resend.** Every attempt is one row in the delivery log (retained
30 days). Search it with `GET /webhook-deliveries`, inspect a single attempt with
`GET /webhook-deliveries/{delivery_id}` (which returns the exact signed `request_body` and
the first 4 KB of `response_body`), and re-deliver with
`POST /webhook-deliveries/{delivery_id}/resend`. The portal exposes the same log and a
resend button.

**Consume idempotently.** Events may arrive more than once and in any order. Dedupe on the
envelope `id`, acknowledge with a fast `2xx`, and process asynchronously. Trust
`data.order.status` over the order of arrival.

## Next steps

- [Your first sale in 15 minutes](https://pay.mmokick.com/developers/guides/first-sale-in-15-minutes) — trigger a real signed `order.paid` in the sandbox.
- [Declared stock](https://pay.mmokick.com/developers/guides/declared-stock) — the same signature scheme secures our calls to you.
- [Errors](https://pay.mmokick.com/developers/errors) — the machine error-code catalog referenced throughout.
