> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moduluslabs.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving Webhooks

> Handle and process webhook notifications for QR Ph transactions

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

<Steps>
  <Step title="Receive POST Request">
    Your server receives a POST request with an encrypted JWE payload
  </Step>

  <Step title="Decrypt Payload">
    Use your secret key to decrypt the JWE-encrypted transaction data
  </Step>

  <Step title="Validate Data">
    Verify the webhook is authentic and hasn't been processed before
  </Step>

  <Step title="Process Transaction">
    Update order status, send confirmations, trigger fulfillment
  </Step>

  <Step title="Respond Quickly">
    Return 200 OK within 10 seconds to acknowledge receipt
  </Step>
</Steps>

## 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
```

<ParamField header="Content-Type" type="string" required>
  Always `application/json`
</ParamField>

<ParamField header="activation-code" type="string">
  Merchant activation code (for multi-merchant setups). Present if you specified an activation code when creating the webhook.
</ParamField>

### Request Body

```json theme={null}
{
  "data": "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIn0.9x4mZBJaKc7QPZ-5u8NOBzjK-cQ2puX9eBaWOWgpGJ6SRvb8P8O2xA.Yp8AbjvIrVH46GKQtLV9PA.zlDcH_ES9xg7Odst..."
}
```

<ParamField body="data" type="string" required>
  JWE-encrypted transaction payload. You must decrypt this using your secret key to access the actual transaction data.
</ParamField>

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

<CodeGroup>
  ```javascript Node.js theme={null}
  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' });
    }
  });
  ```

  ```python Python theme={null}
  from jwcrypto import jwe, jwk
  from jwcrypto.common import json_encode, json_decode
  import json

  def decrypt_webhook(encrypted_data, secret_key):
      try:
          # Create JWK from secret key
          key = jwk.JWK(kty='oct', k=secret_key)

          # Decrypt JWE
          jwe_token = jwe.JWE()
          jwe_token.deserialize(encrypted_data)
          jwe_token.decrypt(key)

          # Parse decrypted payload
          payload = json.loads(jwe_token.payload.decode('utf-8'))

          return payload
      except Exception as e:
          print(f'Decryption failed: {str(e)}')
          raise Exception('Failed to decrypt webhook payload')

  # Usage
  @app.route('/webhooks/modulus', methods=['POST'])
  def handle_webhook():
      data = request.json.get('data')
      SECRET_KEY = os.getenv('MODULUS_SECRET_KEY')

      try:
          payload = decrypt_webhook(data, SECRET_KEY)
          print(f'Decrypted payload: {payload}')

          # Process the webhook
          process_webhook(payload)

          return jsonify({'received': True}), 200
      except Exception as e:
          print(f'Webhook error: {str(e)}')
          return jsonify({'error': 'Processing failed'}), 500
  ```

  ```php PHP theme={null}
  <?php
  use Jose\Component\Core\JWK;
  use Jose\Component\Encryption\JWEDecrypter;
  use Jose\Component\Encryption\Serializer\CompactSerializer;
  use Jose\Component\Core\AlgorithmManager;
  use Jose\Component\Encryption\Algorithm\KeyEncryption\A256KW;
  use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512;

  function decryptWebhook($encryptedData, $secretKey) {
      try {
          // Create JWK from secret key
          $jwk = new JWK([
              'kty' => 'oct',
              'k' => $secretKey
          ]);

          // Setup algorithm managers
          $keyEncryptionAlgorithmManager = new AlgorithmManager([
              new A256KW()
          ]);

          $contentEncryptionAlgorithmManager = new AlgorithmManager([
              new A256CBCHS512()
          ]);

          // Create JWE decrypter
          $jweDecrypter = new JWEDecrypter(
              $keyEncryptionAlgorithmManager,
              $contentEncryptionAlgorithmManager,
              null
          );

          // Deserialize and decrypt
          $serializer = new CompactSerializer();
          $jwe = $serializer->unserialize($encryptedData);
          $jweDecrypter->decryptUsingKey($jwe, $jwk, 0);

          // Parse decrypted payload
          $payload = json_decode($jwe->getPayload(), true);

          return $payload;
      } catch (Exception $e) {
          error_log('Decryption failed: ' . $e->getMessage());
          throw new Exception('Failed to decrypt webhook payload');
      }
  }

  // Usage
  $requestBody = file_get_contents('php://input');
  $request = json_decode($requestBody, true);

  $data = $request['data'];
  $SECRET_KEY = getenv('MODULUS_SECRET_KEY');

  try {
      $payload = decryptWebhook($data, $SECRET_KEY);
      error_log('Decrypted payload: ' . json_encode($payload));

      // Process the webhook
      processWebhook($payload);

      http_response_code(200);
      echo json_encode(['received' => true]);
  } catch (Exception $e) {
      error_log('Webhook error: ' . $e->getMessage());
      http_response_code(500);
      echo json_encode(['error' => 'Processing failed']);
  }
  ```
</CodeGroup>

<Tip>
  **Required Libraries:**

  * Node.js: `npm install node-jose`
  * Python: `pip install jwcrypto`
  * PHP: `composer require web-token/jwt-framework`
</Tip>

## Decrypted Payload Structure

After decryption, the webhook payload contains these fields:

<ResponseField name="action" type="string" required>
  The webhook action type: `QRPH_SUCCESS` or `QRPH_DECLINED`
</ResponseField>

<ResponseField name="referenceNumber" type="string" required>
  Unique reference number for this transaction. Matches the referenceNumber from your Create Dynamic QR Ph request.

  **Example:** `"REF-20240115-ABC123"`
</ResponseField>

<ResponseField name="amount" type="integer" required>
  Transaction amount in the smallest currency unit (centavos for PHP).

  **Example:** `100000` (₱1,000.00)
</ResponseField>

<ResponseField name="currency" type="string" required>
  Three-letter ISO currency code.

  **Example:** `"PHP"`
</ResponseField>

<ResponseField name="transactionDate" type="string" required>
  ISO 8601 timestamp of when the transaction was processed.

  **Example:** `"2024-01-15T14:30:00Z"`
</ResponseField>

<ResponseField name="status" type="string" required>
  Transaction status: `SUCCESS`, `FAILED`, `PENDING`, or `REQUIRES_ACTION`
</ResponseField>

<ResponseField name="merchantId" type="string" required>
  Your merchant identifier.

  **Example:** `"MERCH12345"`
</ResponseField>

<ResponseField name="customerName" type="string">
  Name of the customer who made the payment (if available from the bank).
</ResponseField>

<ResponseField name="bankName" type="string">
  Name of the bank the customer used to pay (if available).
</ResponseField>

### Example Payload: Successful Payment

```json theme={null}
{
  "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

```json theme={null}
{
  "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

<CodeGroup>
  ```javascript Node.js theme={null}
  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
    });
  }
  ```

  ```python Python theme={null}
  async def process_webhook(payload):
      action = payload['action']
      reference_number = payload['referenceNumber']

      # Check for duplicate webhooks
      existing = await db.transactions.find_one({'referenceNumber': reference_number})
      if existing:
          print(f'Webhook already processed: {reference_number}')
          return

      if action == 'QRPH_SUCCESS':
          await handle_successful_payment(payload)
      elif action == 'QRPH_DECLINED':
          await handle_declined_payment(payload)
      else:
          print(f'Unknown webhook action: {action}')

  async def handle_successful_payment(payload):
      reference_number = payload['referenceNumber']
      amount = payload['amount']
      customer_name = payload.get('customerName')

      print(f' Payment successful: {reference_number}')

      # Update order status in database
      await db.orders.update_one(
          {'referenceNumber': reference_number},
          {'$set': {
              'status': 'paid',
              'paidAmount': amount,
              'paidAt': datetime.now(),
              'customerName': customer_name
          }}
      )

      # Send confirmation email
      await send_email(
          to=await get_customer_email(reference_number),
          subject='Payment Received',
          body=f'Your payment of ₱{amount / 100:.2f} has been confirmed.'
      )

      # Trigger order fulfillment
      await fulfill_order(reference_number)

      # Log for audit trail
      await db.webhook_logs.insert_one({
          'referenceNumber': reference_number,
          'action': 'QRPH_SUCCESS',
          'processedAt': datetime.now(),
          'status': 'completed'
      })

  async def handle_declined_payment(payload):
      reference_number = payload['referenceNumber']
      decline_reason = payload.get('declineReason', 'Unknown')

      print(f' Payment declined: {reference_number} - {decline_reason}')

      # Update order status
      await db.orders.update_one(
          {'referenceNumber': reference_number},
          {'$set': {
              'status': 'payment_failed',
              'declineReason': decline_reason,
              'declinedAt': datetime.now()
          }}
      )

      # Notify customer of failure
      await send_email(
          to=await get_customer_email(reference_number),
          subject='Payment Failed',
          body=f'Your payment was declined: {decline_reason}. Please try again or use a different payment method.'
      )

      # Log for audit trail
      await db.webhook_logs.insert_one({
          'referenceNumber': reference_number,
          'action': 'QRPH_DECLINED',
          'processedAt': datetime.now(),
          'reason': decline_reason
      })
  ```
</CodeGroup>

## Idempotency and Duplicate Handling

Modulus Labs may send the same webhook multiple times due to retries. Implement idempotency to prevent duplicate processing.

### Idempotency Strategies

<Tabs>
  <Tab title="Database Unique Constraint">
    ```javascript theme={null}
    // 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;
      }
    }
    ```
  </Tab>

  <Tab title="Check Before Processing">
    ```javascript theme={null}
    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);
    }
    ```
  </Tab>

  <Tab title="Transaction-Based">
    ```javascript theme={null}
    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();
      }
    }
    ```
  </Tab>
</Tabs>

<Warning>
  **Always implement idempotency.** Without it, you risk charging customers multiple times, sending duplicate emails, or over-fulfilling orders.
</Warning>

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

<AccordionGroup>
  <Accordion title="Respond with 200 OK Even on Business Logic Failures" icon="circle-check">
    Return `200 OK` to acknowledge webhook receipt, even if your business logic fails:

    ```javascript theme={null}
    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.
  </Accordion>

  <Accordion title="Return 5xx Only for Temporary Failures" icon="server">
    Use `500`/`503` status codes only when retrying might help:

    ```javascript theme={null}
    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 });
        }
      }
    });
    ```
  </Accordion>

  <Accordion title="Log All Webhook Deliveries" icon="file-lines">
    Track webhook deliveries to monitor retry patterns:

    ```javascript theme={null}
    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
  </Accordion>

  <Accordion title="Implement Exponential Backoff for External Calls" icon="clock">
    If your webhook handler calls external services, add retry logic:

    ```javascript theme={null}
    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));
        }
      }
    }
    ```
  </Accordion>
</AccordionGroup>

## Response Requirements

### Successful Response

Return `200 OK` with a JSON body:

```json theme={null}
{
  "received": true
}
```

<Note>
  The response body content doesn't matter - Modulus Labs only checks the status code. However, returning JSON is a good practice for debugging.
</Note>

### Response Timing

<Warning>
  **Respond within 10 seconds.** If your endpoint takes longer, the request times out and triggers a retry.
</Warning>

### Asynchronous Processing

For long-running operations, respond immediately and process asynchronously:

<CodeGroup>
  ```javascript Node.js (Queue) theme={null}
  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);
  });
  ```

  ```python Python (Celery) theme={null}
  from celery import Celery

  app = Celery('webhooks', broker='redis://localhost:6379')

  @flask_app.route('/webhooks/modulus', methods=['POST'])
  def handle_webhook():
      # Add to queue
      process_webhook_async.delay(request.json)

      # Respond immediately
      return jsonify({'received': True}), 200

  @app.task
  def process_webhook_async(webhook_data):
      data = webhook_data['data']
      payload = decrypt_webhook(data, SECRET_KEY)
      process_webhook(payload)
  ```
</CodeGroup>

## Security Best Practices

<CardGroup cols={2}>
  <Card title="Verify JWE Encryption" icon="shield">
    Always decrypt the JWE payload. Never trust unencrypted webhook data - it could be forged.
  </Card>

  <Card title="Validate Payload Structure" icon="list-check">
    Verify the decrypted payload contains expected fields before processing.
  </Card>

  <Card title="Check Reference Number" icon="magnifying-glass">
    Confirm the referenceNumber exists in your system before fulfilling orders.
  </Card>

  <Card title="Implement Rate Limiting" icon="gauge">
    Limit webhook requests per IP to prevent abuse and DDoS attacks.
  </Card>

  <Card title="Use HTTPS Only" icon="lock">
    Reject non-HTTPS webhook URLs in production to prevent man-in-the-middle attacks.
  </Card>

  <Card title="Log Everything" icon="file-lines">
    Log all webhook receipts, decryption attempts, and processing results for audit trails.
  </Card>
</CardGroup>

## Error Handling

### Graceful Error Recovery

```javascript theme={null}
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

<Steps>
  <Step title="Install ngrok">
    ```bash theme={null}
    npm install -g ngrok
    # or
    brew install ngrok
    ```
  </Step>

  <Step title="Start Your Webhook Server">
    ```bash theme={null}
    node server.js  # Runs on localhost:3000
    ```
  </Step>

  <Step title="Expose with ngrok">
    ```bash theme={null}
    ngrok http 3000
    ```

    ngrok provides a public HTTPS URL:

    ```
    Forwarding: https://abc123.ngrok.io -> http://localhost:3000
    ```
  </Step>

  <Step title="Register Webhook URL">
    ```bash theme={null}
    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"
      }'
    ```
  </Step>

  <Step title="Test with Simulate API">
    ```bash theme={null}
    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!
  </Step>
</Steps>

<Tip>
  **ngrok Inspector:** Visit `http://localhost:4040` to see all webhook requests in the ngrok web interface.
</Tip>

## Monitoring and Alerting

Set up monitoring to detect webhook issues:

<AccordionGroup>
  <Accordion title="Track Processing Success Rate" icon="chart-line">
    ```javascript theme={null}
    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%
  </Accordion>

  <Accordion title="Monitor Processing Time" icon="stopwatch">
    ```javascript theme={null}
    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
  </Accordion>

  <Accordion title="Detect Decryption Failures" icon="key">
    ```javascript theme={null}
    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)
  </Accordion>

  <Accordion title="Track Webhook Delays" icon="clock">
    ```javascript theme={null}
    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
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Payload Structure" icon="brackets-curly" href="/docs/webhooks/payload">
    Complete webhook payload reference
  </Card>

  <Card title="Setup Guide" icon="gear" href="/docs/webhooks/setup">
    Register webhook endpoints
  </Card>

  <Card title="Simulate API" icon="flask" href="/api-reference/webhooks/simulate">
    Test webhooks in sandbox
  </Card>

  <Card title="Webhook API Reference" icon="code" href="/api-reference/webhooks/create">
    Manage webhooks programmatically
  </Card>
</CardGroup>
