cheapkeys

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:

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

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

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):

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:

{
  "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:

{
  "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:

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 PATCHing 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:

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:

{
  "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