Overview
Once you’ve registered a webhook endpoint, Modulus Labs sends POST requests to your server whenever QR Ph transactions complete. This guide covers how to receive, decrypt, and process these webhooks securely.
Receive POST Request
Your server receives a POST request with an encrypted JWE payload
Decrypt Payload
Use your secret key to decrypt the JWE-encrypted transaction data
Validate Data
Verify the webhook is authentic and hasn’t been processed before
Process Transaction
Update order status, send confirmations, trigger fulfillment
Respond Quickly
Return 200 OK within 10 seconds to acknowledge receipt
Modulus Labs sends webhooks as POST requests with this structure:
POST /your-webhook-endpoint HTTP/1.1
Host: your-domain.com
Content-Type: application/json
activation-code: MERCHANT_CODE_123
Merchant activation code (for multi-merchant setups). Present if you specified an activation code when creating the webhook.
Request Body
{
"data" : "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0.9x4mZBJaKc7QPZ-5u8NOBzjK-cQ2puX9eBaWOWgpGJ6SRvb8P8O2xA.Yp8AbjvIrVH46GKQtLV9PA.zlDcH_ES9xg7Odst..."
}
JWE-encrypted transaction payload. You must decrypt this using your secret key to access the actual transaction data.
Decrypting Webhook Payloads
All webhook payloads are encrypted using JWE (JSON Web Encryption) with:
Content Encryption : A256CBC-HS512 (AES-256-CBC with HMAC SHA-512)
Key Encryption : A256KW (AES-256 Key Wrap)
Decryption Examples
const jose = require ( 'node-jose' );
async function decryptWebhook ( encryptedData , secretKey ) {
try {
// Create keystore with your secret key
const keystore = jose . JWK . createKeyStore ();
await keystore . add ( secretKey , 'oct' );
// Decrypt JWE
const result = await jose . JWE . createDecrypt ( keystore ). decrypt ( encryptedData );
// Parse decrypted payload
const payload = JSON . parse ( result . plaintext . toString ());
return payload ;
} catch ( error ) {
console . error ( 'Decryption failed:' , error );
throw new Error ( 'Failed to decrypt webhook payload' );
}
}
// Usage
app . post ( '/webhooks/modulus' , async ( req , res ) => {
const { data } = req . body ;
const SECRET_KEY = process . env . MODULUS_SECRET_KEY ;
try {
const payload = await decryptWebhook ( data , SECRET_KEY );
console . log ( 'Decrypted payload:' , payload );
// Process the webhook
await processWebhook ( payload );
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
console . error ( 'Webhook error:' , error );
res . status ( 500 ). json ({ error: 'Processing failed' });
}
});
Required Libraries:
Node.js: npm install node-jose
Python: pip install jwcrypto
PHP: composer require web-token/jwt-framework
Decrypted Payload Structure
After decryption, the webhook payload contains these fields:
The webhook action type: QRPH_SUCCESS or QRPH_DECLINED
Unique reference number for this transaction. Matches the referenceNumber from your Create Dynamic QR Ph request. Example: "REF-20240115-ABC123"
Transaction amount in the smallest currency unit (centavos for PHP). Example: 100000 (₱1,000.00)
Three-letter ISO currency code. Example: "PHP"
ISO 8601 timestamp of when the transaction was processed. Example: "2024-01-15T14:30:00Z"
Transaction status: SUCCESS, FAILED, PENDING, or REQUIRES_ACTION
Your merchant identifier. Example: "MERCH12345"
Name of the customer who made the payment (if available from the bank).
Name of the bank the customer used to pay (if available).
Example Payload: Successful Payment
{
"action" : "QRPH_SUCCESS" ,
"referenceNumber" : "REF-20240115-ABC123" ,
"amount" : 100000 ,
"currency" : "PHP" ,
"transactionDate" : "2024-01-15T14:30:00Z" ,
"status" : "SUCCESS" ,
"merchantId" : "MERCH12345" ,
"customerName" : "Juan Dela Cruz" ,
"bankName" : "BDO Unibank"
}
Example Payload: Declined Payment
{
"action" : "QRPH_DECLINED" ,
"referenceNumber" : "REF-20240115-XYZ789" ,
"amount" : 50000 ,
"currency" : "PHP" ,
"transactionDate" : "2024-01-15T15:45:00Z" ,
"status" : "FAILED" ,
"merchantId" : "MERCH12345" ,
"declineReason" : "Insufficient funds"
}
Processing Webhooks
Handle Different Webhook Actions
async function processWebhook ( payload ) {
const { action , referenceNumber , amount , status } = payload ;
// Check for duplicate webhooks
const existing = await db . transactions . findOne ({ referenceNumber });
if ( existing ) {
console . log ( `Webhook already processed: ${ referenceNumber } ` );
return ;
}
switch ( action ) {
case 'QRPH_SUCCESS' :
await handleSuccessfulPayment ( payload );
break ;
case 'QRPH_DECLINED' :
await handleDeclinedPayment ( payload );
break ;
default :
console . warn ( `Unknown webhook action: ${ action } ` );
}
}
async function handleSuccessfulPayment ( payload ) {
const { referenceNumber , amount , customerName } = payload ;
console . log ( ` Payment successful: ${ referenceNumber } ` );
// Update order status in database
await db . orders . updateOne (
{ referenceNumber },
{
$set: {
status: 'paid' ,
paidAmount: amount ,
paidAt: new Date (),
customerName: customerName
}
}
);
// Send confirmation email
await sendEmail ({
to: await getCustomerEmail ( referenceNumber ),
subject: 'Payment Received' ,
body: `Your payment of ₱ ${ ( amount / 100 ). toFixed ( 2 ) } has been confirmed.`
});
// Trigger order fulfillment
await fulfillOrder ( referenceNumber );
// Log for audit trail
await db . webhookLogs . insert ({
referenceNumber ,
action: 'QRPH_SUCCESS' ,
processedAt: new Date (),
status: 'completed'
});
}
async function handleDeclinedPayment ( payload ) {
const { referenceNumber , declineReason } = payload ;
console . log ( ` Payment declined: ${ referenceNumber } - ${ declineReason } ` );
// Update order status
await db . orders . updateOne (
{ referenceNumber },
{
$set: {
status: 'payment_failed' ,
declineReason: declineReason ,
declinedAt: new Date ()
}
}
);
// Notify customer of failure
await sendEmail ({
to: await getCustomerEmail ( referenceNumber ),
subject: 'Payment Failed' ,
body: `Your payment was declined: ${ declineReason } . Please try again or use a different payment method.`
});
// Log for audit trail
await db . webhookLogs . insert ({
referenceNumber ,
action: 'QRPH_DECLINED' ,
processedAt: new Date (),
reason: declineReason
});
}
Idempotency and Duplicate Handling
Modulus Labs may send the same webhook multiple times due to retries. Implement idempotency to prevent duplicate processing.
Idempotency Strategies
// Create unique index on referenceNumber
db . transactions . createIndex ({ referenceNumber: 1 }, { unique: true });
async function processWebhook ( payload ) {
try {
// Attempt to insert transaction
await db . transactions . insertOne ({
referenceNumber: payload . referenceNumber ,
amount: payload . amount ,
status: payload . status ,
processedAt: new Date ()
});
// If insert succeeds, this is first time seeing this webhook
await fulfillOrder ( payload . referenceNumber );
} catch ( error ) {
if ( error . code === 11000 ) {
// Duplicate key error - webhook already processed
console . log ( 'Duplicate webhook ignored' );
return ;
}
throw error ;
}
}
async function processWebhook ( payload ) {
const { referenceNumber } = payload ;
// Check if already processed
const existing = await db . transactions . findOne ({ referenceNumber });
if ( existing ) {
console . log ( `Webhook already processed: ${ referenceNumber } ` );
return ; // Skip processing
}
// Process webhook
await db . transactions . insertOne ({
referenceNumber ,
status: payload . status ,
processedAt: new Date ()
});
await fulfillOrder ( referenceNumber );
}
async function processWebhook ( payload ) {
const session = await db . startSession ();
try {
await session . withTransaction ( async () => {
// Check and insert atomically
const existing = await db . transactions . findOne (
{ referenceNumber: payload . referenceNumber },
{ session }
);
if ( existing ) {
console . log ( 'Duplicate webhook' );
return ;
}
// Insert and process
await db . transactions . insertOne ({
referenceNumber: payload . referenceNumber ,
status: payload . status ,
processedAt: new Date ()
}, { session });
await fulfillOrder ( payload . referenceNumber , session );
});
} finally {
await session . endSession ();
}
}
Always implement idempotency. Without it, you risk charging customers multiple times, sending duplicate emails, or over-fulfilling orders.
Webhook Retry Mechanism
If your endpoint doesn’t respond with 200 OK, Modulus Labs automatically retries:
Attempt Timing Description 1 Immediate First delivery attempt when transaction completes 2 +15 minutes First retry if initial delivery fails 3 +30 minutes Second retry (15 minutes after first retry) 4 +45 minutes Final retry (15 minutes after second retry)
What Triggers a Retry?
Your server returns a status code other than 2xx (e.g., 500, 503, 404)
Connection timeout (no response within 10 seconds)
Network errors (DNS failure, connection refused, etc.)
Best Practices for Retries
Respond with 200 OK Even on Business Logic Failures
Return 200 OK to acknowledge webhook receipt, even if your business logic fails: app . post ( '/webhooks/modulus' , async ( req , res ) => {
// Acknowledge receipt immediately
res . status ( 200 ). json ({ received: true });
// Process asynchronously
processWebhookAsync ( req . body ). catch ( error => {
console . error ( 'Business logic failed:' , error );
// Log error, alert team, but don't trigger retry
});
});
Why? Retrying won’t fix business logic errors (e.g., order not found, inventory depleted). Handle these gracefully without triggering retries.
Return 5xx Only for Temporary Failures
Use 500/503 status codes only when retrying might help: app . post ( '/webhooks/modulus' , async ( req , res ) => {
try {
await processWebhook ( req . body );
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
if ( error . code === 'ECONNREFUSED' || error . code === 'ETIMEDOUT' ) {
// Database temporarily unavailable - retry might help
res . status ( 503 ). json ({ error: 'Service temporarily unavailable' });
} else {
// Other errors - log but acknowledge receipt
console . error ( 'Processing error:' , error );
res . status ( 200 ). json ({ received: true });
}
}
});
Log All Webhook Deliveries
Track webhook deliveries to monitor retry patterns: await db . webhookDeliveries . insert ({
referenceNumber: payload . referenceNumber ,
receivedAt: new Date (),
attempt: req . headers [ 'x-webhook-attempt' ] || 1 ,
processed: true
});
Use this data to:
Identify chronic processing failures
Detect unusual retry patterns
Debug webhook issues
Implement Exponential Backoff for External Calls
If your webhook handler calls external services, add retry logic: async function sendConfirmationEmail ( email , data , retries = 3 ) {
for ( let i = 0 ; i < retries ; i ++ ) {
try {
await emailService . send ( email , data );
return ; // Success
} catch ( error ) {
if ( i === retries - 1 ) throw error ;
// Exponential backoff: 1s, 2s, 4s
const delay = Math . pow ( 2 , i ) * 1000 ;
await new Promise ( resolve => setTimeout ( resolve , delay ));
}
}
}
Response Requirements
Successful Response
Return 200 OK with a JSON body:
The response body content doesn’t matter - Modulus Labs only checks the status code. However, returning JSON is a good practice for debugging.
Response Timing
Respond within 10 seconds. If your endpoint takes longer, the request times out and triggers a retry.
Asynchronous Processing
For long-running operations, respond immediately and process asynchronously:
Node.js (Queue)
Python (Celery)
const Queue = require ( 'bull' );
const webhookQueue = new Queue ( 'webhooks' );
app . post ( '/webhooks/modulus' , async ( req , res ) => {
// Add to queue
await webhookQueue . add ({
data: req . body ,
receivedAt: new Date ()
});
// Respond immediately
res . status ( 200 ). json ({ received: true });
});
// Process queue in background
webhookQueue . process ( async ( job ) => {
const { data } = job . data ;
const payload = await decryptWebhook ( data . data , SECRET_KEY );
await processWebhook ( payload );
});
Security Best Practices
Verify JWE Encryption Always decrypt the JWE payload. Never trust unencrypted webhook data - it could be forged.
Validate Payload Structure Verify the decrypted payload contains expected fields before processing.
Check Reference Number Confirm the referenceNumber exists in your system before fulfilling orders.
Implement Rate Limiting Limit webhook requests per IP to prevent abuse and DDoS attacks.
Use HTTPS Only Reject non-HTTPS webhook URLs in production to prevent man-in-the-middle attacks.
Log Everything Log all webhook receipts, decryption attempts, and processing results for audit trails.
Error Handling
Graceful Error Recovery
app . post ( '/webhooks/modulus' , async ( req , res ) => {
const webhookId = generateWebhookId ();
try {
// Log receipt
logger . info ( 'Webhook received' , { webhookId , body: req . body });
// Decrypt
const payload = await decryptWebhook ( req . body . data , SECRET_KEY );
logger . info ( 'Webhook decrypted' , { webhookId , referenceNumber: payload . referenceNumber });
// Validate
if ( ! payload . referenceNumber || ! payload . action ) {
throw new Error ( 'Invalid payload structure' );
}
// Process
await processWebhook ( payload );
logger . info ( 'Webhook processed successfully' , { webhookId });
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
logger . error ( 'Webhook processing failed' , {
webhookId ,
error: error . message ,
stack: error . stack
});
// Alert team for investigation
await alertOncall ( 'Webhook processing failed' , { webhookId , error: error . message });
// Determine if retry is appropriate
if ( isRetryableError ( error )) {
res . status ( 503 ). json ({ error: 'Service temporarily unavailable' });
} else {
// Acknowledge receipt to prevent retries
res . status ( 200 ). json ({ received: true , error: error . message });
}
}
});
function isRetryableError ( error ) {
// Retryable: database connection, external service timeouts
const retryableCodes = [ 'ECONNREFUSED' , 'ETIMEDOUT' , 'ENOTFOUND' ];
return retryableCodes . includes ( error . code );
}
Testing Your Webhook Handler
Local Testing with ngrok
Install ngrok
npm install -g ngrok
# or
brew install ngrok
Start Your Webhook Server
node server.js # Runs on localhost:3000
Expose with ngrok
ngrok provides a public HTTPS URL: Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Register Webhook URL
curl -X POST https://webhooks.sbx.moduluslabs.io/v1/webhooks \
-u sk_your_secret_key: \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://abc123.ngrok.io/webhooks/modulus",
"actions": ["QRPH_SUCCESS", "QRPH_DECLINED"],
"status": "ENABLED"
}'
Test with Simulate API
curl -X POST https://webhooks.sbx.moduluslabs.io/v1/webhooks/qrph/simulate \
-u sk_your_secret_key: \
-H "Content-Type: application/json" \
-d '{ "useCase": "SUCCESS" }'
Check your terminal to see the webhook received!
ngrok Inspector: Visit http://localhost:4040 to see all webhook requests in the ngrok web interface.
Monitoring and Alerting
Set up monitoring to detect webhook issues:
Track Processing Success Rate
const metrics = {
received: 0 ,
processed: 0 ,
failed: 0
};
app . post ( '/webhooks/modulus' , async ( req , res ) => {
metrics . received ++ ;
try {
await processWebhook ( req . body );
metrics . processed ++ ;
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
metrics . failed ++ ;
console . error ( 'Webhook failed:' , error );
res . status ( 500 ). json ({ error: 'Processing failed' });
}
});
// Export metrics for monitoring
app . get ( '/metrics' , ( req , res ) => {
res . json ( metrics );
});
Alert if: Success rate drops below 95%
app . post ( '/webhooks/modulus' , async ( req , res ) => {
const startTime = Date . now ();
await processWebhook ( req . body );
const duration = Date . now () - startTime ;
logger . info ( 'Webhook processed' , { duration });
if ( duration > 5000 ) {
alertOncall ( 'Webhook processing slow' , { duration });
}
res . status ( 200 ). json ({ received: true });
});
Alert if: Processing takes longer than 5 seconds
Detect Decryption Failures
let decryptionFailures = 0 ;
async function decryptWebhook ( data , key ) {
try {
return await performDecryption ( data , key );
} catch ( error ) {
decryptionFailures ++ ;
if ( decryptionFailures > 5 ) {
alertOncall ( 'Multiple decryption failures' , { count: decryptionFailures });
}
throw error ;
}
}
Alert if: More than 5 decryption failures in 1 hour (may indicate key mismatch or attack)
app . post ( '/webhooks/modulus' , async ( req , res ) => {
const payload = await decryptWebhook ( req . body . data , SECRET_KEY );
const receivedAt = new Date ();
const transactionDate = new Date ( payload . transactionDate );
const delay = receivedAt - transactionDate ;
logger . info ( 'Webhook delay' , {
referenceNumber: payload . referenceNumber ,
delay: delay
});
if ( delay > 60000 ) { // More than 1 minute
alertOncall ( 'Webhook delayed' , { delay , referenceNumber: payload . referenceNumber });
}
await processWebhook ( payload );
res . status ( 200 ). json ({ received: true });
});
Alert if: Webhooks consistently arrive more than 1 minute after transaction
Next Steps