Skip to main content

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

EventDescriptionTrigger
payment.completedPayment succeededTerminal returns SUCCESS status
payment.failedPayment declined or errorTerminal returns FAILED status
payment.cancelledUser cancelled paymentTerminal returns CANCELLED status
payment.timeoutTerminal didn’t respond90-second timeout reached

Webhook Payload Structure

All webhook payloads follow this structure:
{
  "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

eventType
string
required
The type of event that triggered this webhook.Values: payment.completed, payment.failed, payment.cancelled, payment.timeout
eventId
string
required
Unique identifier for this event. Use this for idempotency checks.Example: "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ"
timestamp
string
required
ISO 8601 timestamp of when the event occurred.
data
object
required
Event-specific payload data containing the payment result.

Event Payload Examples

payment.completed

{
  "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

{
  "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

{
  "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

{
  "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

HeaderDescription
webhook-idUnique identifier for this webhook delivery
webhook-timestampUnix timestamp (seconds) when the webhook was sent
webhook-signatureHMAC-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
Always verify signatures before processing webhooks. Reject requests with invalid or expired signatures.

Node.js Example

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

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:
RequirementValue
Response timeWithin 15 seconds
Success statusAny 2xx status code
Retry on failureUp to 3 retries with exponential backoff
If your endpoint doesn’t respond with a 2xx status within 15 seconds, the webhook is considered failed and will be retried.

Retry Schedule

AttemptDelay after failure
1st retry30 seconds
2nd retry2 minutes
3rd retry10 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

Use the eventId field to ensure you don’t process the same event twice. Store processed event IDs and skip duplicates:
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...
}
Respond to webhooks immediately, then process asynchronously:
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');
});
Subscribe to all relevant event types and handle each appropriately:
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;
}
  • Always verify webhook signatures
  • Use HTTPS endpoints only
  • Validate the webhook-timestamp is recent
  • Consider IP allowlisting if available

Troubleshooting

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
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
Cause: Retries due to slow responses or 5xx errors.Solution: Implement idempotency using eventId. Store processed event IDs and skip duplicates.

Next Steps