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
2xxreturned within 10 seconds. Timeouts and3xx/4xx/5xxall 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 seeorder.refundedbeforeorder.paid, trust the embeddeddata.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 asv2=alongsidev1=(comma-separated), sov1=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 tostatus: disabled, fires awebhook.disabledevent to your other active endpoints, and sends a portal alert and email. - A
410 Goneresponse disables the endpoint immediately — you are telling us it is dead. - Re-enable an auto-disabled endpoint by
PATCHing it back tostatus: active, which requires a passing2xxtest-fire first (otherwise409/invalid_statewith the failing probe result). Success resetsconsecutive_failuresto 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
- Your first sale in 15 minutes — trigger a real signed
order.paidin the sandbox. - Declared stock — the same signature scheme secures our calls to you.
- Errors — the machine error-code catalog referenced throughout.