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 HTTP curl JavaScript TypeScript Python
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 "
}
} curl -X POST " https://yourapp.com/webhooks/conomy " \
-H " Content-Type: application/json " \
-H " X-Webhook-Signature: f8a23d5e0c7c2b6e4a8f9d0c5b3d7e1a7f6c4e2d9b0a5f8c3e1d6b9a7c4e2d1f " \
-d ' {
"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"
}
} ' const crypto = require ( " node:crypto " );
function signRawBody ( rawBody , secret ) {
return crypto
. createHmac ( " sha256 " , secret )
. update ( rawBody , " utf8 " )
. digest ( " hex " );
}
const payload = {
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 "
}
};
const rawBody = JSON . stringify ( payload );
const signature = signRawBody ( rawBody , process . env . CONOMY_WEBHOOK_SECRET );
console . log ({ rawBody , signature }); import crypto from " node:crypto " ;
type PaymentStatus =
| " ATTEMPT "
| " CREATED "
| " AUTHORIZED "
| " REQUIRES_REVIEW "
| " CAPTURED "
| " RECEIVED "
| " SETTLED "
| " UNSETTLED "
| " EXPIRED "
| " FAILED "
| " REFUNDED " ;
type ConomyWebhookBody = {
eventType : string ;
reason ? : string ;
timestamp : string ;
transaction : {
id : string ;
externalId ? : string ;
totalAmount ? : string ;
currency ? : string ;
product ? : string ;
purchaseAmount ? : string ;
purchaseCurrency ? : string ;
status : PaymentStatus ;
type ? : string ;
description ? : string ;
parentPaymentId ? : string ;
relatedPaymentId ? : string ;
settledAt ? : string ;
expiredAt ? : string ;
unsettledAt ? : string ;
unsettledReason ? : string ;
settlementBatchId ? : string ;
documentationStatus ? : " PENDING_UPLOAD " | " UPLOADED " | " APPROVED " | " REJECTED " | " SENT " ;
customerId ? : string ;
originator ? : Record < string , unknown >;
};
identity : {
id : string ;
email ? : string ;
};
};
export function signRawBody ( rawBody : string , secret : string ) {
return crypto . createHmac ( " sha256 " , secret ). update ( rawBody , " utf8 " ). digest ( " hex " );
} import hashlib
import hmac
import os
def sign_raw_body ( raw_body : bytes ) -> str :
return hmac . new (
os . environ [ " CONOMY_WEBHOOK_SECRET " ]. encode (),
raw_body ,
hashlib . sha256 ,
). hexdigest ()
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 JavaScript TypeScript Python Go
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 });
} import crypto from " node:crypto " ;
function isValidSignature ( rawBody : string , receivedSignature : string | undefined , secret : string ) {
const received = Buffer . from ( receivedSignature || "" , " hex " );
const expected = Buffer . from (
crypto . createHmac ( " sha256 " , secret ). update ( rawBody , " utf8 " ). digest ( " hex " ),
" hex "
);
return received . length === expected . length && crypto . timingSafeEqual ( received , expected );
} import hashlib
import hmac
import json
import os
def handle_webhook ( request ):
raw_body = request . get_data ()
received = request . headers . get ( " X-Webhook-Signature " , "" )
expected = hmac . new (
os . environ [ " CONOMY_WEBHOOK_SECRET " ]. encode (),
raw_body ,
hashlib . sha256 ,
). hexdigest ()
if not hmac . compare_digest ( expected , received ):
return { " error " : " invalid_signature " }, 401
body = json . loads ( raw_body )
tx = body [ " transaction " ]
idempotency_key = body [ " eventType " ] + " : " + tx [ " id " ]
save_webhook_event_once ( idempotency_key , body )
enqueue_transaction_sync ( tx [ " id " ])
return { " received " : True }, 200 func validSignature ( rawBody [] byte , receivedSignature string , secret string ) bool {
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ( rawBody )
expected := hex . EncodeToString ( mac . Sum ( nil ))
return hmac . Equal ([] byte ( expected ), [] byte ( receivedSignature ))
}
Validate X-Webhook-Signature before trusting the transaction data.
Use (eventType, transaction.id) as the idempotency key.
Persist or enqueue the event, then return 2xx quickly.
Use GET /payments/{id} in a worker if your fulfillment logic needs the latest canonical state.
Treat unknown eventType values as safe to store and ignore until your integration supports them.
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.