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 HTTP curl JavaScript TypeScript Python Go Rust
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 "
} curl -X POST " https://yourapp.com/webhooks/conomy " \
-H " Content-Type: application/json " \
-d ' {
"event": "Transaction.Captured",
"data": {
"transaction": {
"id": "67a0307eaddea901a60144ec",
"purchaseAmount": "50000",
"totalAmount": "50000",
"externalId": "123456",
"currency": "COP",
"status": "CAPTURED"
}
},
"signature": "f8a23d5e0c7c2b6e4a8f9d0c5b3d7e1a7f6c4e2d9b0a5f8c3e1d6b9a7c4e2d1f"
} ' const crypto = require ( " node:crypto " );
const secret = process . env . CONOMY_WEBHOOK_SECRET ;
function signWebhookBody ( unsignedBody ) {
return crypto
. createHmac ( " sha256 " , secret )
. update ( JSON . stringify ( unsignedBody ))
. digest ( " hex " );
}
const unsignedBody = {
event : " Transaction.Captured " ,
data : {
transaction : {
id : " 67a0307eaddea901a60144ec " ,
purchaseAmount : " 50000 " ,
totalAmount : " 50000 " ,
externalId : " 123456 " ,
currency : " COP " ,
status : " CAPTURED "
}
}
};
const signedPayload = {
... unsignedBody ,
signature : signWebhookBody ( unsignedBody )
};
console . log ( signedPayload ); import crypto from " node:crypto " ;
type TransactionStatus = " CAPTURED " | " RECEIVED " | " FAILED " ;
type ConomyWebhookBody = {
event : string ;
data : {
transaction : {
id : string ;
purchaseAmount : string ;
totalAmount : string ;
externalId : string ;
currency : string ;
status : TransactionStatus ;
};
};
};
export function signWebhookBody ( body : ConomyWebhookBody , secret : string ) {
return crypto
. createHmac ( " sha256 " , secret )
. update ( JSON . stringify ( body ))
. digest ( " hex " );
} import hashlib
import hmac
import json
import os
def sign_webhook_body ( unsigned_body ):
return hmac . new (
os . environ [ " CONOMY_WEBHOOK_SECRET " ]. encode (),
json . dumps ( unsigned_body , separators =( " , " , " : " )). encode (),
hashlib . sha256 ,
). hexdigest () func signWebhookBody ( unsignedBody [] byte , secret string ) string {
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ( unsignedBody )
return hex . EncodeToString ( mac . Sum ( nil ))
} fn sign_webhook_body ( unsigned_body : & [ u8 ], secret : & str ) -> String {
let mut mac = HmacSha256 :: new_from_slice ( secret . as_bytes ()) . unwrap ();
mac . update ( unsigned_body );
hex :: encode ( mac . finalize () . into_bytes ())
}
Your handler should remove signature, rebuild the unsigned JSON body, compute HMAC-SHA256 with your secret, and compare it in constant time.
Request JavaScript TypeScript Python Go Rust
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 });
} import crypto from " node:crypto " ;
type SignedConomyWebhook = {
event : string ;
data : {
transaction : {
id : string ;
status : " CAPTURED " | " RECEIVED " | " FAILED " ;
};
};
signature : string ;
};
function isValidSignature ( body : SignedConomyWebhook , secret : string ) {
const received = Buffer . from ( body . signature || "" , " hex " );
const unsignedBody = {
event : body . event ,
data : body . data
};
const expected = Buffer . from (
crypto . createHmac ( " sha256 " , secret ). update ( JSON . stringify ( unsignedBody )). digest ( " hex " ),
" hex "
);
return received . length === expected . length && crypto . timingSafeEqual ( received , expected );
} import hashlib
import hmac
import json
import os
def handle_webhook ( request ):
body = request . get_json ()
received = body . get ( " signature " , "" )
unsigned_body = {
" event " : body [ " event " ],
" data " : body [ " data " ],
}
expected = hmac . new (
os . environ [ " CONOMY_WEBHOOK_SECRET " ]. encode (),
json . dumps ( unsigned_body , separators =( " , " , " : " )). encode (),
hashlib . sha256 ,
). hexdigest ()
if not hmac . compare_digest ( expected , received ):
return { " error " : " invalid_signature " }, 401
tx = body [ " data " ][ " transaction " ]
idempotency_key = body [ " event " ] + " : " + tx [ " id " ] + " : " + tx [ " status " ]
save_webhook_event_once ( idempotency_key , body )
enqueue_transaction_sync ( tx [ " id " ])
return { " received " : True }, 200 func validSignature ( body SignedWebhook , secret string ) bool {
unsigned := UnsignedWebhook {
Event : body . Event ,
Data : body . Data ,
}
payload , _ := json . Marshal ( unsigned )
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ( payload )
expected := hex . EncodeToString ( mac . Sum ( nil ))
return hmac . Equal ([] byte ( expected ), [] byte ( body . Signature ))
} fn valid_signature ( body : & SignedWebhook , secret : & str ) -> bool {
let unsigned = UnsignedWebhook {
event : body . event . clone (),
data : body . data . clone (),
};
let payload = serde_json :: to_vec ( & unsigned ) . unwrap ();
let mut mac = HmacSha256 :: new_from_slice ( secret . as_bytes ()) . unwrap ();
mac . update ( & payload );
let expected = hex :: encode ( mac . finalize () . into_bytes ());
constant_time_eq :: constant_time_eq ( expected . as_bytes (), body . signature . as_bytes ())
}
Validate the signature field before trusting the transaction data.
Use (event, transaction.id, transaction.status) 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 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.