Skip to content

Suggested

Dashboard

RecipesValidate webhook signatures

Validate webhook signatures

Verify Conomy payment webhooks, persist events idempotently, and mirror transaction state from CAPTURED, RECEIVED, and FAILED updates.

Webhooks are how your backend learns that a payment changed after the original request. Redirect URLs are for user experience. Webhooks are for state.

Conomy sends payment events to the URL registered with PUT /clients/webhook. The payload uses eventType, timestamp, transaction, and identity.

Security

Validate X-Webhook-Signature before trusting the payload. The header is a 64-character HMAC-SHA256 hexadecimal digest generated from the raw request body, using your shared WEBHOOK_SECRET.

The incoming body contains the event type and transaction data. The signature is in the HTTP header, not in the JSON body.

Request
POST /webhooks/conomy HTTP/1.1
Host: yourapp.com
Content-Type: application/json
X-Webhook-Signature: f8a23d5e0c7c2b6e4a8f9d0c5b3d7e1a7f6c4e2d9b0a5f8c3e1d6b9a7c4e2d1f

{
"eventType": "payment.received",
"reason": "settled",
"timestamp": "2026-05-18T20:00:00.000Z",
"transaction": {
  "id": "67a0307eaddea901a60144ec",
  "externalId": "merchant-order-12345",
  "totalAmount": "50000",
  "currency": "COP",
  "product": "COP:COP",
  "purchaseAmount": "50000",
  "purchaseCurrency": "COP",
  "status": "RECEIVED",
  "type": "TOPUP_ACCOUNT",
  "description": "Topup from bank transfer",
  "customerId": "67a02e10ad9aa801a60144e0"
},
"identity": {
  "id": "67a02f34ad9aa801a60144ea",
  "email": "merchant@example.com"
}
}

Your handler must compute HMAC-SHA256 over the raw request body exactly as received and compare it to X-Webhook-Signature in constant time.

Request
import crypto from "node:crypto";

function timingSafeHexEqual(left, right) {
const a = Buffer.from(left || "", "hex");
const b = Buffer.from(right || "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function handleWebhook(req, res) {
const rawBody = await getRawBodyAsString(req);
const received = req.headers["x-webhook-signature"];
const expected = crypto
  .createHmac("sha256", process.env.CONOMY_WEBHOOK_SECRET)
  .update(rawBody, "utf8")
  .digest("hex");

if (!timingSafeHexEqual(expected, received)) {
  return res.status(401).json({ error: "invalid_signature" });
}

const body = JSON.parse(rawBody);
const tx = body.transaction;
const idempotencyKey = body.eventType + ":" + tx.id;

await saveWebhookEventOnce(idempotencyKey, body);
await enqueueTransactionSync(tx.id);

return res.status(200).json({ received: true });
}
  1. Validate X-Webhook-Signature before trusting the transaction data.
  2. Use (eventType, transaction.id) as the idempotency key.
  3. Persist or enqueue the event, then return 2xx quickly.
  4. Use GET /payments/{id} in a worker if your fulfillment logic needs the latest canonical state.
  5. Treat unknown eventType values as safe to store and ignore until your integration supports them.
  6. reason is a diagnostic discriminator that produced the eventType. Log it for support, but never branch business logic on it — it is not part of the stable contract.
Recommended flow

Use Capture and reconcile to map payment lifecycle events into your own order, ledger, or fulfillment state.