Skip to content

Receiving and verifying webhooks

We send an event to your endpoint every time a payment, refund or payout changes status. Events are signed the Standard Webhooks way, so you verify them with a ready library — no signing code of your own.

One HTTPS endpoint per project: a named public host, port 443 or 8443. An IP address, another port, a username in the URL or a fragment is 422 url_not_allowed.

examples/curl/webhooks/endpoint/set.sh
#!/usr/bin/env bash
# Point events at your HTTPS address. Keep the secret of the first answer: it is shown once.
set -euo pipefail
. "$(dirname "$0")/../../signed-request.sh"
body=$(cat <<JSON
{
"url": "${WEBHOOK_URL:-https://shop.example.lk/webhooks/processing}"
}
JSON
)
mka_request PUT "/v1/webhook-endpoint" "$body"

The first answer, 201, carries the signing secret, whsec_…, once. Store it with your other secrets. Changing the address later answers 200 and keeps the secret. Lost it? Roll it.

examples/node/webhooks/receive.mjs
// Receive webhooks: verify each one with the Standard Webhooks library, act on it once.
import { createServer } from "node:http";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…, answered once by PUT /v1/webhook-endpoint
const handled = new Set(); // webhook-id of the events acted on; keep this in your database
const versions = new Map(); // the highest version applied, per payment, refund or payout
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
let event;
try {
// The exact bytes received, before any JSON parsing; the timestamp must be within 5 minutes.
event = webhook.verify(Buffer.concat(chunks).toString("utf8"), req.headers);
} catch {
res.writeHead(400).end();
return;
}
if (!handled.has(req.headers["webhook-id"])) {
handled.add(req.headers["webhook-id"]);
apply(event);
}
res.writeHead(204).end(); // answer 2xx within 15 seconds; do slow work after answering
});
}).listen(Number(process.env.PORT ?? 8080));
function apply(event) {
const [subject, version] = {
"payment.status_changed": [event.payment_id, event.payment_version],
"refund.status_changed": [event.refund_id, event.refund_version],
"payout.status_changed": [event.payout_id, event.payout_version],
}[event.type] ?? [event.event_id, 0];
// Events may come late or twice: an older version than the one applied changes nothing.
if ((versions.get(subject) ?? 0) > version) return;
versions.set(subject, version);
console.log(`verified ${event.type} ${subject} v${version} ${event.status ?? ""}`);
}
  1. Verify over the raw body, before parsing it: the signature covers the exact bytes. A framework that parses JSON first and serialises it again breaks the signature.
  2. Reject a stale timestamp. The libraries refuse one more than 5 minutes off; keep it so.
  3. Answer 2xx within 15 seconds, then do the slow work. Anything else — a timeout, a redirect, a 5xx — is retried.
  4. Act once per webhook-id: a retry carries the same id (ordering).

Each event carries three headers:

Header
webhook-id The event’s id; the same on every retry.
webhook-timestamp Unix seconds of this attempt.
webhook-signature v1, and the base64 HMAC-SHA256 of id.timestamp.body with your secret. While a rolled secret overlaps, one signature per secret, space-separated.
Type When Key fields
payment.status_changed A payment became requires_action, processing, succeeded, failed or cancelled. payment_id, payment_version, status, data.merchant_order_id, data.amount, data.amount_received
refund.status_changed A refund was asked, taken, sent or failed. refund_id, refund_version, payment_id, status
payout.status_changed A payout was created, taken, sent, failed or cancelled. payout_id, payout_version, status, data.merchant_payout_id, data.fee
webhook.test You asked for a test. —

Every field is in the events reference. Ignore event types and fields you do not know: new ones are added within v1.

examples/curl/webhooks/endpoint/send-test-event.sh
#!/usr/bin/env bash
# Send a webhook.test event now and see how your endpoint answered.
set -euo pipefail
. "$(dirname "$0")/../../signed-request.sh"
mka_request POST "/v1/webhook-endpoint/test"

The answer says whether your endpoint took it and with what status. Every event and every attempt is also listed under Developers → Webhooks in the console, and by the API:

examples/curl/webhooks/deliveries/list.sh
#!/usr/bin/env bash
# Your deliveries, newest first, 50 a page.
set -euo pipefail
. "$(dirname "$0")/../../signed-request.sh"
mka_request GET "/v1/webhook-deliveries"