Skip to main content

Overview

Webhook security is critical to ensure that requests to your endpoint are actually from Modulus Labs and haven’t been tampered with. All webhook notifications include cryptographic signatures that you must verify before processing.
Always verify webhook signatures before processing webhook data. Failing to verify signatures could allow attackers to send fake transaction notifications to your endpoint.

How Webhook Security Works

Modulus Labs uses JWE (JSON Web Encryption) to secure webhook payloads. Every webhook notification you receive is encrypted and must be decrypted using your encryption key.
1

We Encrypt the Payload

Modulus Labs encrypts the webhook payload using your encryption key with JWE (A256KW algorithm with A256CBC-HS512 encryption)
2

We Send Encrypted Token

The encrypted payload is sent as a Token field in the webhook POST request to your endpoint
3

You Decrypt the Token

Your endpoint decrypts the token using your encryption key to access the transaction data
4

You Process the Event

Once decrypted and verified, process the transaction event securely

Decrypting Webhook Payloads

Node.js Example

const jose = require('jose');

app.post('/webhooks/modulus', async (req, res) => {
  try {
    const encryptionKey = process.env.MODULUS_ENCRYPTION_KEY;
    const encryptedToken = req.body.Token;

    // Decrypt the JWE token
    const key = Buffer.from(encryptionKey, 'utf-8');
    const { plaintext } = await jose.compactDecrypt(encryptedToken, key);

    // Parse the decrypted payload
    const event = JSON.parse(new TextDecoder().decode(plaintext));

    // Verify the event structure
    if (!event.transactionId || !event.status) {
      throw new Error('Invalid webhook payload structure');
    }

    // Process the event
    await handleWebhookEvent(event);

    // Always respond with 200 OK
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook verification failed:', error);
    res.status(400).json({ error: 'Invalid webhook' });
  }
});

Python Example

from jose import jwe
import json

@app.route('/webhooks/modulus', methods=['POST'])
def webhook_handler():
    try:
        encryption_key = os.getenv('MODULUS_ENCRYPTION_KEY')
        encrypted_token = request.json['Token']

        # Decrypt the JWE token
        decrypted = jwe.decrypt(encrypted_token, encryption_key)
        event = json.loads(decrypted)

        # Verify event structure
        if 'transactionId' not in event or 'status' not in event:
            raise ValueError('Invalid webhook payload')

        # Process the event
        handle_webhook_event(event)

        return jsonify({'received': True}), 200
    except Exception as e:
        print(f'Webhook verification failed: {e}')
        return jsonify({'error': 'Invalid webhook'}), 400

C# / .NET Example

using Jose; // Install-Package jose-jwt

[HttpPost("/webhooks/modulus")]
public async Task<IActionResult> WebhookHandler([FromBody] WebhookRequest request)
{
    try
    {
        var encryptionKey = Environment.GetEnvironmentVariable("MODULUS_ENCRYPTION_KEY");
        var keyBytes = Encoding.UTF8.GetBytes(encryptionKey);

        // Decrypt the JWE token
        var decryptedJson = JWT.Decode(request.Token, keyBytes);
        var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(decryptedJson);

        // Verify event structure
        if (string.IsNullOrEmpty(webhookEvent.TransactionId) ||
            string.IsNullOrEmpty(webhookEvent.Status))
        {
            return BadRequest(new { error = "Invalid webhook payload" });
        }

        // Process the event
        await HandleWebhookEvent(webhookEvent);

        return Ok(new { received = true });
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Webhook verification failed: {ex.Message}");
        return BadRequest(new { error = "Invalid webhook" });
    }
}

Security Best Practices

Use HTTPS Only

Always use HTTPS endpoints for webhooks. HTTP is not supported in production and is insecure.

Verify All Payloads

Always decrypt and verify webhook payloads before processing to prevent fake notifications.

Keep Keys Secret

Never expose your encryption key in client-side code, logs, or version control.

Validate Structure

Always validate that decrypted payloads contain expected fields before processing.

Handle Errors Gracefully

Return appropriate HTTP status codes and log verification failures for debugging.

Implement Idempotency

Handle duplicate webhook deliveries by checking transaction IDs you’ve already processed.

Webhook Payload Structure

After decryption, webhook payloads follow this structure:

Success Event

{
  "transactionId": "0ce32626-08cf-405c-adab-6d384a8871da",
  "merchantReferenceNumber": "ORDER-2025-001",
  "amount": "500.00",
  "currency": "PHP",
  "status": "SUCCESS",
  "timestamp": "2024-01-15T10:30:00Z",
  "qrPhTransactionId": "QRPH123456789",
  "sourceAccount": "09171234567"
}

Declined Event

{
  "transactionId": "0ce32626-08cf-405c-adab-6d384a8871da",
  "merchantReferenceNumber": "ORDER-2025-001",
  "amount": "500.00",
  "currency": "PHP",
  "status": "DECLINED",
  "timestamp": "2024-01-15T10:30:00Z",
  "declineReason": "INSUFFICIENT_FUNDS"
}

Implementing Idempotency

Modulus Labs may send the same webhook multiple times (e.g., due to network retries). Implement idempotency to handle duplicates:
// Track processed transaction IDs
const processedTransactions = new Set();

async function handleWebhookEvent(event) {
  // Check if already processed
  if (processedTransactions.has(event.transactionId)) {
    console.log('Duplicate webhook, already processed:', event.transactionId);
    return; // Skip processing
  }

  // Process the transaction
  await updateOrderStatus(event.merchantReferenceNumber, event.status);

  // Mark as processed
  processedTransactions.add(event.transactionId);

  // Persist to database for durability
  await db.saveProcessedWebhook(event.transactionId);
}

Testing Webhook Security

Use the Simulate Webhook API to test your webhook endpoint security in sandbox:
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 notification to your registered endpoint, allowing you to verify your decryption and validation logic.

Common Security Issues

Symptoms:
  • Cannot decrypt webhook token
  • “Invalid token” errors
Solutions:
  • Verify you’re using the correct encryption key from environment variables
  • Check that encryption key matches the one provided by Modulus Labs
  • Ensure you’re using the correct JWE algorithms (A256KW, A256CBC-HS512)
  • Verify no whitespace or encoding issues with the key
Symptoms:
  • Not receiving webhook notifications
  • Webhook delivery failures
Solutions:
  • Verify your endpoint is publicly accessible over the internet
  • Check firewall rules allow incoming HTTPS connections
  • Ensure SSL certificate is valid and not self-signed
  • Test endpoint accessibility from external networks
Symptoms:
  • Decryption succeeds but payload validation fails
  • Unexpected payload structure
Solutions:
  • Check webhook event type and expected payload structure
  • Verify you’re handling both SUCCESS and DECLINED event types
  • Log full decrypted payload to debug unexpected structures
  • Ensure backward compatibility for new fields
Symptoms:
  • Same transaction processed multiple times
  • Duplicate order fulfillment
Solutions:
  • Implement idempotency using transaction ID tracking
  • Use database to persist processed transaction IDs
  • Check transaction ID before processing
  • Use database transactions to prevent race conditions

IP Whitelisting

For additional security, you can whitelist Modulus Labs webhook IP addresses:
Contact Modulus Labs support to get the list of IP addresses for webhook notifications in your environment (sandbox vs production).
Example firewall rule:
# Allow webhooks from Modulus Labs IPs only
iptables -A INPUT -p tcp --dport 443 -s <MODULUS_IP_RANGE> -j ACCEPT

Webhook Retry Logic

Modulus Labs implements automatic retry logic for webhook delivery:
  • Retry Attempts: Up to 3 additional attempts
  • Retry Interval: 15 minutes between attempts
  • Success Criteria: Your endpoint returns 200 OK within 10 seconds
Always respond with 200 OK as quickly as possible. Process webhook events asynchronously if needed to ensure fast response times.

Next Steps