Webhooks
Moorkyz Cards can notify your own backend, CRM, loyalty platform, or automation tool whenever something happens with a reward from one of your cards. Every event is recorded in Cards first; delivery to your endpoint happens afterwards and never blocks the collector. Configure webhooks per company under Company → Integrations in the app.
Overview
- A collector scans or enters a code and claims a card.
- Cards validates the code, updates the collection, and rolls the reward profile.
- If a reward was won, the reward unlock and an outbox event are written in one database transaction.
- A background worker posts the event to your webhook URL with a signature.
- Your system responds with any 2xx status; otherwise Cards retries automatically.
Unlocking and redeeming are separate concepts: unlocked means the collector now owns the reward, redeemed means they actually used it.
Supported events
| Event | When | Status |
|---|---|---|
reward.unlocked | A collector won a reward from one of your cards and now owns it. | Available |
reward.redeemed | A collector marked a reward as used. | Available |
reward.revoked | A reward a collector owned was withdrawn. | Reserved for later |
card.collected | A collector claimed a card into their collection. | Reserved for later |
card.redeemed | A collector used a card at your location. | Reserved for later |
webhook.test | Sent when you press "Send test event" in the dashboard. | Available |
Request format
Deliveries are POST requests with a JSON body and Content-Type: application/json. The body always carries apiVersion (currently "1"); new fields may be added within a version, but existing fields will not change meaning or disappear without a new version.
HTTP headers
| Header | Meaning |
|---|---|
X-Moorkyz-Event | Event type, e.g. reward.unlocked. |
X-Moorkyz-Event-Id | Unique, immutable id of this event. Identical on every retry. |
X-Moorkyz-Timestamp | Unix time (seconds) when this delivery attempt was signed. |
X-Moorkyz-Signature | sha256=<hex>, an HMAC-SHA256 over timestamp + "." + rawBody. |
X-Moorkyz-Delivery-Attempt | 1 for the first delivery, then incrementing on each retry. |
Example: reward.unlocked
{
"apiVersion": "1",
"event": "reward.unlocked",
"eventId": "evt_9f1c2a7d3b4e4f5a8c6d7e8f90a1b2c3",
"occurredAt": "2026-09-11T18:55:00.000Z",
"companyId": "cmp_company_id",
"customer": {
"id": "usr_user_id",
"displayName": "Casey Collector",
"email": "[email protected]",
"externalCustomerId": null
},
"reward": {
"id": "rwd_reward_id",
"name": "Free coffee",
"type": "FREE_ITEM",
"description": "One coffee at the booth."
},
"unlock": {
"id": "unl_unlock_id",
"unlockedAt": "2026-09-11T18:55:00.000Z",
"status": "AVAILABLE"
},
"card": {
"designId": "crd_design_id",
"title": "Nordwind · Barista",
"instanceId": "ins_instance_id"
}
}Example: reward.redeemed
{
"apiVersion": "1",
"event": "reward.redeemed",
"eventId": "evt_0b2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f",
"occurredAt": "2026-09-12T09:12:00.000Z",
"companyId": "cmp_company_id",
"customer": {
"id": "usr_user_id",
"displayName": "Casey Collector",
"email": "[email protected]",
"externalCustomerId": null
},
"reward": {
"id": "rwd_reward_id",
"name": "Free coffee",
"type": "FREE_ITEM"
},
"unlock": {
"id": "unl_unlock_id",
"unlockedAt": "2026-09-11T18:55:00.000Z",
"redeemedAt": "2026-09-12T09:12:00.000Z",
"status": "REDEEMED"
}
}customer.externalCustomerId is reserved for a later mapping to your own customer identifiers and is currently always null. Use customer.id or customer.email to match collectors to accounts in your system.
Verifying signatures
Each company has its own signing secret (shown once when it is created; regenerate it from the dashboard if you lose it). Compute the HMAC over the raw request body exactly as received, prefixed with the timestamp header and a dot, and compare it to the signature header using a constant-time comparison. Reject deliveries whose timestamp is older than 5 minutes.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyMoorkyzWebhook(rawBody, headers, secret) {
const timestamp = headers["x-moorkyz-timestamp"];
const signature = headers["x-moorkyz-signature"];
if (!timestamp || !signature) return false;
// Reject stale deliveries (replay protection).
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: use express.raw({ type: "application/json" }) so rawBody is untouched.
app.post("/webhooks/moorkyz", (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyMoorkyzWebhook(rawBody, req.headers, process.env.MOORKYZ_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
if (alreadyProcessed(event.eventId)) return res.status(200).end(); // duplicate delivery
handle(event);
markProcessed(event.eventId);
res.status(200).end();
});Python
import hmac, hashlib, time
def verify_moorkyz_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Moorkyz-Timestamp")
signature = headers.get("X-Moorkyz-Signature")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Response codes
- 2xx — delivered. Respond quickly and process the event asynchronously if your work takes time.
- 408, 425, 429, 5xx, timeouts, connection errors — treated as temporary; Cards retries.
- Other 4xx and 3xx — treated as a configuration problem; the event is marked failed and can be retried manually from the dashboard. Redirects are not followed.
Requests time out after 10 seconds.
Retries
Cards makes up to 5 automatic attempts. The first attempt happens immediately; later attempts follow this schedule:
- Attempt 2: after 1 minute
- Attempt 3: after 5 minutes
- Attempt 4: after 30 minutes
- Attempt 5: after 2 hours
After the last attempt the event is kept as gave up (dead letter), never deleted. You can retry failed or dead-lettered events from the dashboard at any time; the retry reuses the same eventId.
Idempotency
Retries and manual redeliveries can cause you to receive the same event more than once. Store every eventId you have processed and ignore a delivery whose id you already know, answering with 2xx. On the Cards side, a reward can only be unlocked once per claimed card and a logical event is only ever created once, so you will never receive two different event ids for the same unlock.
Security notes
- Webhook URLs must use HTTPS and point to a publicly reachable host.
- The signing secret is stored by Cards and never shown again after creation. Keep it in your secret store.
- Requests carry the
User-Agent: Moorkyz-Cards-Webhooks/1header. - Only the company owner can change webhook settings, send test events, or retry deliveries.
Testing
From Company → Integrations you can send a webhook.test event to your endpoint. It uses the same headers and signature scheme as real events, and the dashboard shows the HTTP status your endpoint returned along with the recent deliveries, their attempt counts, and any errors.
Open your companies to configure webhooks.