> ## 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 webhook payloads and verify signatures for payment notifications

## Overview

When you use `webhookMode: true` in payment requests, payment results are delivered to your configured webhook endpoints instead of being returned in the HTTP response. This page covers how to receive, verify, and process these webhook payloads.

***

## Event Types

| Event               | Description               | Trigger                             |
| ------------------- | ------------------------- | ----------------------------------- |
| `payment.completed` | Payment succeeded         | Terminal returns `SUCCESS` status   |
| `payment.failed`    | Payment declined or error | Terminal returns `FAILED` status    |
| `payment.cancelled` | User cancelled payment    | Terminal returns `CANCELLED` status |
| `payment.timeout`   | Terminal didn't respond   | 90-second timeout reached           |

***

## Webhook Payload Structure

All webhook payloads follow this structure:

```json theme={null}
{
  "eventType": "payment.completed",
  "eventId": "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ",
  "timestamp": "2024-01-15T10:37:30.000Z",
  "data": {
    "transactionId": "TXN-20240115-001",
    "status": "SUCCESS",
    "amount": "99.99",
    "currency": "USD",
    "paymentMethod": "CARD",
    "authorizationCode": "AUTH123456",
    "receiptData": "...",
    "terminalId": "TERM-001",
    "metadata": {
      "orderId": "ORD-12345"
    }
  }
}
```

### Payload Fields

<ParamField body="eventType" type="string" required>
  The type of event that triggered this webhook.

  **Values:** `payment.completed`, `payment.failed`, `payment.cancelled`, `payment.timeout`
</ParamField>

<ParamField body="eventId" type="string" required>
  Unique identifier for this event. Use this for idempotency checks.

  **Example:** `"evt_01HQ3K4M5N6P7R8S9T0UVWXYZ"`
</ParamField>

<ParamField body="timestamp" type="string" required>
  ISO 8601 timestamp of when the event occurred.
</ParamField>

<ParamField body="data" type="object" required>
  Event-specific payload data containing the payment result.

  <Expandable title="Data fields">
    <ParamField body="transactionId" type="string" required>
      Your transaction identifier from the original payment request.
    </ParamField>

    <ParamField body="status" type="string" required>
      Payment status: `SUCCESS`, `FAILED`, `CANCELLED`, or `TIMEOUT`.
    </ParamField>

    <ParamField body="amount" type="string" required>
      Payment amount.
    </ParamField>

    <ParamField body="currency" type="string" required>
      ISO 4217 currency code.
    </ParamField>

    <ParamField body="paymentMethod" type="string" required>
      Payment method used.
    </ParamField>

    <ParamField body="authorizationCode" type="string">
      Authorization code from payment processor (success only).
    </ParamField>

    <ParamField body="errorCode" type="string">
      Error code if payment failed.
    </ParamField>

    <ParamField body="errorMessage" type="string">
      Human-readable error message if payment failed.
    </ParamField>

    <ParamField body="receiptData" type="string">
      Opaque receipt data from terminal.
    </ParamField>

    <ParamField body="terminalId" type="string" required>
      Terminal that processed the payment.
    </ParamField>

    <ParamField body="metadata" type="object">
      Your custom metadata from the original payment request.
    </ParamField>
  </Expandable>
</ParamField>

***

## Event Payload Examples

### payment.completed

```json theme={null}
{
  "eventType": "payment.completed",
  "eventId": "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ",
  "timestamp": "2024-01-15T10:37:30.000Z",
  "data": {
    "transactionId": "TXN-20240115-001",
    "status": "SUCCESS",
    "amount": "99.99",
    "currency": "USD",
    "paymentMethod": "CARD",
    "authorizationCode": "AUTH123456",
    "receiptData": "...",
    "terminalId": "TERM-001",
    "metadata": {
      "orderId": "ORD-12345"
    }
  }
}
```

### payment.failed

```json theme={null}
{
  "eventType": "payment.failed",
  "eventId": "evt_01HQ3K5N6P7R8S9T0UVWXYZA",
  "timestamp": "2024-01-15T10:38:00.000Z",
  "data": {
    "transactionId": "TXN-20240115-002",
    "status": "FAILED",
    "amount": "150.00",
    "currency": "USD",
    "paymentMethod": "CARD",
    "errorCode": "INSUFFICIENT_FUNDS",
    "errorMessage": "Card declined due to insufficient funds",
    "terminalId": "TERM-001",
    "metadata": {
      "orderId": "ORD-12346"
    }
  }
}
```

### payment.cancelled

```json theme={null}
{
  "eventType": "payment.cancelled",
  "eventId": "evt_01HQ3K6P7R8S9T0UVWXYZAB",
  "timestamp": "2024-01-15T10:39:00.000Z",
  "data": {
    "transactionId": "TXN-20240115-003",
    "status": "CANCELLED",
    "amount": "75.00",
    "currency": "USD",
    "paymentMethod": "CARD",
    "terminalId": "TERM-001",
    "metadata": {
      "orderId": "ORD-12347"
    }
  }
}
```

### payment.timeout

```json theme={null}
{
  "eventType": "payment.timeout",
  "eventId": "evt_01HQ3K7R8S9T0UVWXYZABC",
  "timestamp": "2024-01-15T10:40:30.000Z",
  "data": {
    "transactionId": "TXN-20240115-004",
    "status": "TIMEOUT",
    "amount": "200.00",
    "currency": "USD",
    "paymentMethod": "CARD",
    "terminalId": "TERM-001",
    "metadata": {
      "orderId": "ORD-12348"
    }
  }
}
```

***

## Signature Verification

All webhook requests include headers for signature verification. You must verify signatures to ensure webhooks are authentic and haven't been tampered with.

### Webhook Headers

| Header              | Description                                             |
| ------------------- | ------------------------------------------------------- |
| `webhook-id`        | Unique identifier for this webhook delivery             |
| `webhook-timestamp` | Unix timestamp (seconds) when the webhook was sent      |
| `webhook-signature` | HMAC-SHA256 signature in format `v1,<base64-signature>` |

### Verification Algorithm

1. Extract headers: `webhook-id`, `webhook-timestamp`, `webhook-signature`
2. Check timestamp is within 5-minute tolerance window
3. Construct signed content: `${webhook-id}.${webhook-timestamp}.${raw-body}`
4. Compute HMAC-SHA256 using your webhook secret (base64-decoded)
5. Compare computed signature with provided signature

<Warning>
  Always verify signatures before processing webhooks. Reject requests with invalid or expired signatures.
</Warning>

### Node.js Example

```javascript theme={null}
const crypto = require('crypto');

function verifyWebhookSignature(payload, headers, secret) {
  const webhookId = headers['webhook-id'];
  const webhookTimestamp = headers['webhook-timestamp'];
  const webhookSignature = headers['webhook-signature'];

  if (!webhookId || !webhookTimestamp || !webhookSignature) {
    throw new Error('Missing required webhook headers');
  }

  // Check timestamp is within 5-minute tolerance
  const tolerance = 5 * 60; // 5 minutes in seconds
  const now = Math.floor(Date.now() / 1000);
  const timestamp = parseInt(webhookTimestamp, 10);

  if (Math.abs(now - timestamp) > tolerance) {
    throw new Error('Webhook timestamp outside tolerance window');
  }

  // Construct signed content
  const signedContent = `${webhookId}.${webhookTimestamp}.${payload}`;

  // Compute expected signature
  const secretBytes = Buffer.from(secret, 'base64');
  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent)
    .digest('base64');

  // Parse and compare signatures (format: "v1,<signature> v1,<signature>")
  const signatures = webhookSignature.split(' ');
  for (const sig of signatures) {
    const [version, hash] = sig.split(',');
    if (version === 'v1' && hash === expectedSignature) {
      return true;
    }
  }

  throw new Error('Invalid webhook signature');
}

// Express.js example
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();

  try {
    verifyWebhookSignature(payload, req.headers, process.env.WEBHOOK_SECRET);

    const event = JSON.parse(payload);
    console.log('Received event:', event.eventType, event.eventId);

    // Process the webhook
    switch (event.eventType) {
      case 'payment.completed':
        handlePaymentCompleted(event.data);
        break;
      case 'payment.failed':
        handlePaymentFailed(event.data);
        break;
      case 'payment.cancelled':
        handlePaymentCancelled(event.data);
        break;
      case 'payment.timeout':
        handlePaymentTimeout(event.data);
        break;
    }

    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook verification failed:', error.message);
    res.status(401).send('Invalid signature');
  }
});
```

### Python Example

```python theme={null}
import hmac
import hashlib
import base64
import time
import json
from flask import Flask, request, abort

app = Flask(__name__)

def verify_webhook_signature(payload: str, headers: dict, secret: str) -> bool:
    webhook_id = headers.get('webhook-id')
    webhook_timestamp = headers.get('webhook-timestamp')
    webhook_signature = headers.get('webhook-signature')

    if not all([webhook_id, webhook_timestamp, webhook_signature]):
        raise ValueError('Missing required webhook headers')

    # Check timestamp is within 5-minute tolerance
    tolerance = 5 * 60  # 5 minutes in seconds
    now = int(time.time())
    timestamp = int(webhook_timestamp)

    if abs(now - timestamp) > tolerance:
        raise ValueError('Webhook timestamp outside tolerance window')

    # Construct signed content
    signed_content = f'{webhook_id}.{webhook_timestamp}.{payload}'

    # Compute expected signature
    secret_bytes = base64.b64decode(secret)
    expected_signature = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    # Parse and compare signatures (format: "v1,<signature> v1,<signature>")
    signatures = webhook_signature.split(' ')
    for sig in signatures:
        parts = sig.split(',')
        if len(parts) == 2:
            version, sig_hash = parts
            if version == 'v1' and sig_hash == expected_signature:
                return True

    raise ValueError('Invalid webhook signature')


@app.route('/webhooks/payments', methods=['POST'])
def handle_webhook():
    payload = request.get_data(as_text=True)

    try:
        verify_webhook_signature(payload, request.headers, WEBHOOK_SECRET)

        event = json.loads(payload)
        print(f"Received event: {event['eventType']} {event['eventId']}")

        # Process the webhook
        if event['eventType'] == 'payment.completed':
            handle_payment_completed(event['data'])
        elif event['eventType'] == 'payment.failed':
            handle_payment_failed(event['data'])
        elif event['eventType'] == 'payment.cancelled':
            handle_payment_cancelled(event['data'])
        elif event['eventType'] == 'payment.timeout':
            handle_payment_timeout(event['data'])

        return 'OK', 200

    except ValueError as e:
        print(f'Webhook verification failed: {e}')
        abort(401)
```

***

## Response Requirements

Your webhook endpoint must respond according to these requirements:

| Requirement      | Value                                    |
| ---------------- | ---------------------------------------- |
| Response time    | Within **15 seconds**                    |
| Success status   | Any `2xx` status code                    |
| Retry on failure | Up to 3 retries with exponential backoff |

<Note>
  If your endpoint doesn't respond with a `2xx` status within 15 seconds, the webhook is considered failed and will be retried.
</Note>

### Retry Schedule

| Attempt   | Delay after failure |
| --------- | ------------------- |
| 1st retry | 30 seconds          |
| 2nd retry | 2 minutes           |
| 3rd retry | 10 minutes          |

After all retries are exhausted, the webhook delivery is marked as failed. You can query the transaction status using `GET /v1/transactions/{transactionId}` to reconcile any missed webhooks.

***

## Best Practices

<AccordionGroup>
  <Accordion title="Idempotency">
    Use the `eventId` field to ensure you don't process the same event twice. Store processed event IDs and skip duplicates:

    ```javascript theme={null}
    const processedEvents = new Set();

    function handleWebhook(event) {
      if (processedEvents.has(event.eventId)) {
        console.log('Duplicate event, skipping:', event.eventId);
        return;
      }

      processedEvents.add(event.eventId);
      // Process the event...
    }
    ```
  </Accordion>

  <Accordion title="Acknowledge quickly">
    Respond to webhooks immediately, then process asynchronously:

    ```javascript theme={null}
    app.post('/webhooks/payments', (req, res) => {
      // Verify signature first
      verifyWebhookSignature(req.body, req.headers, secret);

      // Queue for async processing
      eventQueue.push(JSON.parse(req.body));

      // Respond immediately
      res.status(200).send('OK');
    });
    ```
  </Accordion>

  <Accordion title="Handle all event types">
    Subscribe to all relevant event types and handle each appropriately:

    ```javascript theme={null}
    switch (event.eventType) {
      case 'payment.completed':
        await updateOrderStatus(event.data.transactionId, 'paid');
        await sendReceipt(event.data);
        break;
      case 'payment.failed':
        await updateOrderStatus(event.data.transactionId, 'failed');
        await notifyCustomer(event.data.errorMessage);
        break;
      case 'payment.cancelled':
        await updateOrderStatus(event.data.transactionId, 'cancelled');
        break;
      case 'payment.timeout':
        // Check transaction status to reconcile
        const txn = await getTransaction(event.data.transactionId);
        await handleTimeoutReconciliation(txn);
        break;
    }
    ```
  </Accordion>

  <Accordion title="Secure your endpoint">
    * Always verify webhook signatures
    * Use HTTPS endpoints only
    * Validate the webhook-timestamp is recent
    * Consider IP allowlisting if available
  </Accordion>
</AccordionGroup>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signature verification fails">
    **Common causes:**

    * Using the wrong webhook secret
    * Modifying the payload before verification (e.g., parsing JSON first)
    * Clock skew exceeding 5-minute tolerance
    * Decoding the secret incorrectly (must be base64-decoded)

    **Solutions:**

    1. Verify you're using the secret from endpoint creation
    2. Use the raw request body for signature verification
    3. Ensure your server clock is synchronized with NTP
    4. Base64-decode the secret before computing HMAC
  </Accordion>

  <Accordion title="Not receiving webhooks">
    **Check:**

    1. Endpoint URL is publicly accessible
    2. Endpoint is not returning errors
    3. Firewall allows incoming HTTPS connections
    4. Endpoint status is `active` (not `disabled`)
    5. You're subscribed to the relevant event types
  </Accordion>

  <Accordion title="Duplicate webhooks">
    **Cause:** Retries due to slow responses or 5xx errors.

    **Solution:** Implement idempotency using `eventId`. Store processed event IDs and skip duplicates.
  </Accordion>
</AccordionGroup>

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhook Setup" icon="gear" href="./webhooks-setup">
    Configure webhook endpoints
  </Card>

  <Card title="HTTP Endpoints" icon="server" href="./endpoints">
    Use webhookMode in payment requests
  </Card>

  <Card title="Get Transaction" icon="magnifying-glass" href="/api-reference/terminal-gateway/get-transaction">
    Query transaction status for reconciliation
  </Card>

  <Card title="Data Types" icon="brackets-curly" href="../reference">
    Complete data type reference
  </Card>
</CardGroup>
