Skip to main content

Overview

All webhook payloads from Modulus Labs are encrypted using JWE (JSON Web Encryption) to protect sensitive transaction data. This page provides a complete reference for the payload structure, encryption details, and all available fields.
Always decrypt and verify webhook payloads. Never trust webhook data without proper JWE decryption and validation. Unencrypted data could be forged or tampered with.

Webhook Request Structure

When Modulus Labs sends a webhook to your endpoint, the request looks like this:

HTTP Request

POST /your-webhook-endpoint HTTP/1.1
Host: your-domain.com
Content-Type: application/json
activation-code: MERCHANT_CODE_123

Request Body

{
  "data": "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0.9x4mZBJaKc7QPZ..."
}
The data field contains a JWE-encrypted string that you must decrypt to access the actual transaction details.

JWE Encryption Specification

Webhook payloads use JSON Web Encryption with the following algorithms:

Content Encryption

Algorithm: A256CBC-HS512AES-256-CBC with HMAC SHA-512 for authenticated encryption

Key Encryption

Algorithm: A256KWAES-256 Key Wrap for secure key encryption

Serialization Format

Format: Compact SerializationStandard JWE compact format (five base64url segments separated by dots)

Secret Key

Your Secret: Modulus Labs Secret KeySame key used for QR API authentication

JWE Structure

eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0  ← Protected Header
.
9x4mZBJaKc7QPZ-5u8NOBzjK-cQ2puX9eBaWOWgpGJ6SRvb8P8O2xA  ← Encrypted Key
.
Yp8AbjvIrVH46GKQtLV9PA  ← Initialization Vector
.
zlDcH_ES9xg7OdstFYn...  ← Ciphertext (encrypted payload)
.
pL3zxK9bG4mQ7vN8wR2cT  ← Authentication Tag
You don’t need to manually parse these segments. JWE libraries handle the decryption process automatically.

Decrypting the Payload

Use your secret key to decrypt the JWE payload.

Required Libraries

npm install node-jose

Decryption Implementation

const jose = require('node-jose');

async function decryptWebhookPayload(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 JSON payload
    const payload = JSON.parse(result.plaintext.toString());

    return payload;
  } catch (error) {
    throw new Error(`Failed to decrypt webhook: ${error.message}`);
  }
}

// Usage
const SECRET_KEY = process.env.MODULUS_SECRET_KEY;
const webhookData = req.body.data;

const payload = await decryptWebhookPayload(webhookData, SECRET_KEY);
console.log('Decrypted payload:', payload);

Decrypted Payload Structure

After decryption, the webhook payload is a JSON object with the following structure:

Complete Field Reference

action
string
required
The webhook action that triggered this notification.Possible values:
  • QRPH_SUCCESS - Payment completed successfully
  • QRPH_DECLINED - Payment failed or was declined
Example: "QRPH_SUCCESS"
referenceNumber
string
required
Unique reference number for this transaction. This matches the referenceNumber you provided when creating the Dynamic QR Ph code.Use this field to:
  • Match webhook to original QR creation request
  • Prevent duplicate processing (idempotency)
  • Look up order in your database
Format: Alphanumeric string, typically 1-50 charactersExample: "REF-20240115-ABC123"
amount
integer
required
Transaction amount in the smallest currency unit.
  • For PHP: Amount in centavos (100 = ₱1.00)
  • For USD: Amount in cents (100 = $1.00)
Example: 100000 represents ₱1,000.00
currency
string
required
Three-letter ISO 4217 currency code.Possible values: "PHP", "USD"Example: "PHP"
transactionDate
string
required
ISO 8601 timestamp indicating when the transaction was processed by the payment network.Format: YYYY-MM-DDTHH:mm:ssZExample: "2024-01-15T14:30:00Z"
status
string
required
Current transaction status.Possible values:
  • SUCCESS - Payment completed successfully
  • FAILED - Payment failed
  • PENDING - Payment processing (rare for webhooks)
  • REQUIRES_ACTION - Additional action needed (rare for webhooks)
Example: "SUCCESS"
merchantId
string
required
Your merchant identifier assigned by Modulus Labs.Example: "MERCH12345"
customerName
string
Name of the customer who made the payment, as provided by their bank.Availability: May be null if the bank doesn’t provide this information.Example: "Juan Dela Cruz"
bankName
string
Name of the bank or payment provider the customer used to complete the payment.Availability: May be null if not provided by the payment network.Example: "BDO Unibank", "GCash", "Maya"
bankReferenceNumber
string
Bank’s internal reference number for this transaction.Availability: Present only for successful payments, may be null for declined payments.Use case: Reconciliation with bank statements, dispute resolutionExample: "BANK-REF-987654321"
declineReason
string
Reason the payment was declined (only present when action is QRPH_DECLINED).Possible values:
  • "Insufficient funds"
  • "Invalid account"
  • "Transaction limit exceeded"
  • "Card expired"
  • "Declined by bank"
  • "Unknown error"
Example: "Insufficient funds"
metadata
object
Custom key-value pairs you included when creating the Dynamic QR Ph code. This is returned exactly as you sent it.Use case: Store order ID, customer ID, or other identifiers to link webhook to your systemExample:
{
  "orderId": "ORD-12345",
  "customerId": "CUST-789",
  "productSku": "WIDGET-001"
}

Payload Examples

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",
  "bankReferenceNumber": "BANK-REF-987654321",
  "metadata": {
    "orderId": "ORD-12345",
    "customerId": "CUST-789"
  }
}

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",
  "metadata": {
    "orderId": "ORD-67890",
    "customerId": "CUST-456"
  }
}

E-wallet Payment (GCash)

{
  "action": "QRPH_SUCCESS",
  "referenceNumber": "REF-20240115-GCASH01",
  "amount": 250000,
  "currency": "PHP",
  "transactionDate": "2024-01-15T16:20:00Z",
  "status": "SUCCESS",
  "merchantId": "MERCH12345",
  "customerName": "Maria Santos",
  "bankName": "GCash",
  "bankReferenceNumber": "GCASH-123456789",
  "metadata": {
    "orderId": "ORD-55555",
    "productType": "digital"
  }
}

Field Validation

Validate the decrypted payload before processing:
function validateWebhookPayload(payload) {
  const errors = [];

  // Required fields
  if (!payload.action) errors.push('Missing action field');
  if (!payload.referenceNumber) errors.push('Missing referenceNumber field');
  if (typeof payload.amount !== 'number') errors.push('Invalid or missing amount field');
  if (!payload.currency) errors.push('Missing currency field');
  if (!payload.status) errors.push('Missing status field');

  // Valid action values
  const validActions = ['QRPH_SUCCESS', 'QRPH_DECLINED'];
  if (payload.action && !validActions.includes(payload.action)) {
    errors.push(`Invalid action: ${payload.action}`);
  }

  // Valid status values
  const validStatuses = ['SUCCESS', 'FAILED', 'PENDING', 'REQUIRES_ACTION'];
  if (payload.status && !validStatuses.includes(payload.status)) {
    errors.push(`Invalid status: ${payload.status}`);
  }

  // Amount validation
  if (payload.amount <= 0) {
    errors.push('Amount must be positive');
  }

  // Currency validation
  const validCurrencies = ['PHP', 'USD'];
  if (payload.currency && !validCurrencies.includes(payload.currency)) {
    errors.push(`Unsupported currency: ${payload.currency}`);
  }

  if (errors.length > 0) {
    throw new Error(`Payload validation failed: ${errors.join(', ')}`);
  }

  return true;
}

// Usage
try {
  const payload = await decryptWebhookPayload(webhookData, SECRET_KEY);
  validateWebhookPayload(payload);

  // Proceed with processing
  await processWebhook(payload);
} catch (error) {
  console.error('Invalid webhook:', error.message);
  // Log for investigation but still return 200 OK
}

Common Validation Patterns

Check that the referenceNumber matches a QR code you actually created:
async function processWebhook(payload) {
  const { referenceNumber } = payload;

  // Look up order in database
  const order = await db.orders.findOne({ referenceNumber });

  if (!order) {
    console.warn(`Unknown reference number: ${referenceNumber}`);
    // Log suspicious webhook for investigation
    await logSuspiciousWebhook(payload);
    return; // Don't process
  }

  // Proceed with processing
  await updateOrderStatus(order, payload);
}
Verify the payment amount matches what you expected:
async function processWebhook(payload) {
  const order = await db.orders.findOne({ referenceNumber: payload.referenceNumber });

  if (order.amount !== payload.amount) {
    console.error('Amount mismatch', {
      expected: order.amount,
      received: payload.amount,
      referenceNumber: payload.referenceNumber
    });

    // Alert fraud team
    await alertFraudTeam('Amount mismatch detected', { order, payload });

    // Don't fulfill order
    return;
  }

  // Amount matches, proceed
  await fulfillOrder(order);
}
Detect replayed or delayed webhooks:
async function processWebhook(payload) {
  const transactionDate = new Date(payload.transactionDate);
  const now = new Date();
  const ageMinutes = (now - transactionDate) / 1000 / 60;

  if (ageMinutes > 60) {
    console.warn('Old webhook received', {
      referenceNumber: payload.referenceNumber,
      ageMinutes: ageMinutes
    });

    // Still process but log for investigation
    await logDelayedWebhook(payload, ageMinutes);
  }

  await processTransaction(payload);
}
Ensure action and status fields are consistent:
function validateStatusConsistency(payload) {
  const { action, status } = payload;

  const validCombinations = {
    'QRPH_SUCCESS': ['SUCCESS'],
    'QRPH_DECLINED': ['FAILED']
  };

  const validStatuses = validCombinations[action];

  if (!validStatuses || !validStatuses.includes(status)) {
    throw new Error(`Inconsistent action (${action}) and status (${status})`);
  }

  return true;
}

Security Considerations

Always Decrypt

Never trust the encrypted payload without decryption. An attacker could send fake webhook requests.

Verify Signature

JWE provides authenticated encryption - the decryption process validates data integrity.

Use HTTPS

Your webhook endpoint must use HTTPS to prevent man-in-the-middle attacks.

Rate Limiting

Implement rate limiting on your webhook endpoint to prevent abuse.

Log Everything

Log all webhook receipts, decryption attempts, and processing results.

Handle Errors Gracefully

Don’t expose sensitive information in error messages.

Troubleshooting Decryption Issues

Possible causes:
  • Wrong secret key
  • Secret key for different environment (sandbox vs production)
  • Secret key has extra whitespace or newline characters
Solutions:
// Trim whitespace from secret key
const SECRET_KEY = process.env.MODULUS_SECRET_KEY.trim();

// Verify key format
console.log('Key length:', SECRET_KEY.length);
console.log('Key starts with:', SECRET_KEY.substring(0, 3));
Possible causes:
  • Decryption succeeded but JSON parsing failed
  • Empty payload
  • Incorrect plaintext handling
Solutions:
const result = await jose.JWE.createDecrypt(keystore).decrypt(encryptedData);

// Log raw plaintext before parsing
console.log('Raw plaintext:', result.plaintext.toString());

// Safely parse JSON
try {
  const payload = JSON.parse(result.plaintext.toString());
  return payload;
} catch (error) {
  console.error('JSON parse error:', error);
  console.error('Raw plaintext:', result.plaintext.toString());
  throw error;
}
Possible causes:
  • JWE library doesn’t support A256CBC-HS512 or A256KW
  • Outdated library version
Solutions:
# Update to latest version
npm install node-jose@latest

# Or use alternative library
npm install jose
// Using 'jose' library instead
const jose = require('jose');

async function decrypt(encryptedData, secretKey) {
  const secret = new TextEncoder().encode(secretKey);
  const { plaintext } = await jose.compactDecrypt(encryptedData, secret);
  return JSON.parse(new TextDecoder().decode(plaintext));
}
Possible causes:
  • Middleware modifying request body
  • Character encoding issues
  • Request body not properly buffered
Solutions:
// Express: Use raw body parser for webhook endpoint
app.use('/webhooks/modulus', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/modulus', (req, res) => {
  console.log('Raw body:', req.rawBody);
  console.log('Parsed body:', req.body);

  // Use parsed body
  const { data } = req.body;
});

Testing with Sample Payloads

Use the Simulate API to generate test webhooks with real encrypted payloads:
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"
  }'
This sends a real encrypted webhook to your registered endpoint, allowing you to test decryption end-to-end.

Simulate API Reference

Complete documentation for testing webhooks

Best Practices

Load your secret key once at startup, not on every webhook:
// Good: Load once
const SECRET_KEY = process.env.MODULUS_SECRET_KEY;

app.post('/webhooks/modulus', async (req, res) => {
  const payload = await decrypt(req.body.data, SECRET_KEY);
});

// Bad: Load repeatedly
app.post('/webhooks/modulus', async (req, res) => {
  const key = process.env.MODULUS_SECRET_KEY; // Don't do this
  const payload = await decrypt(req.body.data, key);
});
Always validate payload structure before business logic:
app.post('/webhooks/modulus', async (req, res) => {
  try {
    const payload = await decrypt(req.body.data, SECRET_KEY);

    // Validate first
    validatePayload(payload);

    // Then process
    await processWebhook(payload);

    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(200).json({ received: true }); // Still acknowledge
  }
});
Save the encrypted payload for audit and debugging:
await db.webhookLogs.insert({
  referenceNumber: payload.referenceNumber,
  encryptedData: req.body.data, // Store encrypted version
  decryptedPayload: payload,     // Store decrypted version
  receivedAt: new Date(),
  processed: true
});
Some fields like customerName or bankName may be null:
const {
  customerName = 'Unknown',
  bankName = 'Unknown',
  declineReason = 'No reason provided'
} = payload;

// Use defaults when fields are missing
console.log(`Payment from ${customerName} via ${bankName}`);

Next Steps