Skip to content

Suggested

API 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 transaction status updates to your configured URL with one of three payment states: CAPTURED, RECEIVED, or FAILED.

Security

Validate the signature before trusting the payload. The signature field is a 64-character HMAC-SHA256 hexadecimal digest generated from the JSON body without the signature field, using your shared WEBHOOK_SECRET.

The incoming body contains the event name, transaction data, and signature.

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

{
"event": "Transaction.Captured",
"data": {
  "transaction": {
    "id": "67a0307eaddea901a60144ec",
    "purchaseAmount": "50000",
    "totalAmount": "50000",
    "externalId": "123456",
    "currency": "COP",
    "status": "CAPTURED"
  }
},
"signature": "f8a23d5e0c7c2b6e4a8f9d0c5b3d7e1a7f6c4e2d9b0a5f8c3e1d6b9a7c4e2d1f"
}

Your handler should remove signature, rebuild the unsigned JSON body, compute HMAC-SHA256 with your secret, and compare it 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 body = await req.json();
const received = body.signature;
const unsignedBody = {
  event: body.event,
  data: body.data
};

const expected = crypto
  .createHmac("sha256", process.env.CONOMY_WEBHOOK_SECRET)
  .update(JSON.stringify(unsignedBody))
  .digest("hex");

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

const tx = body.data.transaction;
const idempotencyKey = body.event + ":" + tx.id + ":" + tx.status;

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

return res.status(200).json({ received: true });
}
  1. Validate the signature field before trusting the transaction data.
  2. Use (event, transaction.id, transaction.status) 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 events as safe to store and ignore until your integration supports them.
Recommended flow

Use Capture and reconcile to map CAPTURED, RECEIVED, and FAILED into your own order, ledger, or fulfillment state.