Skip to main content

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.
1

Receive POST Request

Your server receives a POST request with an encrypted JWE payload
2

Decrypt Payload

Use your secret key to decrypt the JWE-encrypted transaction data
3

Validate Data

Verify the webhook is authentic and hasn’t been processed before
4

Process Transaction

Update order status, send confirmations, trigger fulfillment
5

Respond Quickly

Return 200 OK within 10 seconds to acknowledge receipt

Webhook Request Format

Modulus Labs sends webhooks as POST requests with this structure:

Request Headers

POST /your-webhook-endpoint HTTP/1.1
Host: your-domain.com
Content-Type: application/json
activation-code: MERCHANT_CODE_123
Content-Type
string
required
Always application/json
activation-code
string
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..."
}
data
string
required
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:
action
string
required
The webhook action type: QRPH_SUCCESS or QRPH_DECLINED
referenceNumber
string
required
Unique reference number for this transaction. Matches the referenceNumber from your Create Dynamic QR Ph request.Example: "REF-20240115-ABC123"
amount
integer
required
Transaction amount in the smallest currency unit (centavos for PHP).Example: 100000 (₱1,000.00)
currency
string
required
Three-letter ISO currency code.Example: "PHP"
transactionDate
string
required
ISO 8601 timestamp of when the transaction was processed.Example: "2024-01-15T14:30:00Z"
status
string
required
Transaction status: SUCCESS, FAILED, PENDING, or REQUIRES_ACTION
merchantId
string
required
Your merchant identifier.Example: "MERCH12345"
customerName
string
Name of the customer who made the payment (if available from the bank).
bankName
string
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;
  }
}
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:
AttemptTimingDescription
1ImmediateFirst delivery attempt when transaction completes
2+15 minutesFirst retry if initial delivery fails
3+30 minutesSecond retry (15 minutes after first retry)
4+45 minutesFinal 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

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.
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 });
    }
  }
});
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
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:
{
  "received": true
}
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:
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

1

Install ngrok

npm install -g ngrok
# or
brew install ngrok
2

Start Your Webhook Server

node server.js  # Runs on localhost:3000
3

Expose with ngrok

ngrok http 3000
ngrok provides a public HTTPS URL:
Forwarding: https://abc123.ngrok.io -> http://localhost:3000
4

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"
  }'
5

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:
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
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