Skip to main content

Webhooks

Webhooks allow you to receive real-time notifications when events occur in your ChainPal account, such as when a payment is completed or fails.

Configuration

Configure your webhook URLs in the ChainPal Dashboard under Integration > Webhooks. You can set separate webhook URLs for:
  • Live Environment: Receives events from production payments
  • Test Environment: Receives events from test payments
You must generate a Webhook Signing Secret before you can configure webhook URLs. This secret is used to verify that webhooks originate from ChainPal.
Webhook URLs vs Callback URLs
  • Webhook URL: A server-to-server endpoint that receives event notifications (POST requests) from ChainPal. Used for backend processing.
  • Callback URL: A frontend redirect URL where customers are sent after payment. The payment ID and reference are appended as query parameters. Used for displaying success/failure pages to customers.
Configure Webhook URLs in the dashboard. Callback URLs can be set per-payment when initializing a payment.

Webhook Delivery

When an event occurs, ChainPal sends an HTTP POST request to your configured webhook URL with:
  • Content-Type: application/json
  • Method: POST
  • Timeout: 10 seconds
  • Retries: Up to 7 attempts with exponential backoff

Retry Schedule

If your endpoint doesn’t respond with a 2xx status code, we retry with the following schedule:
AttemptDelay After Previous
1Immediate
230 seconds
32 minutes
415 minutes
51 hour
66 hours
724 hours
After 7 failed attempts (spanning approximately 31 hours), the webhook is marked as exhausted and no further retries are attempted.

Event Payload Structure

All webhook events follow this structure:
{
  "id": "evt_abc123xyz",
  "type": "payment.completed",
  "environment": "live",
  "createdAt": "2024-01-15T14:30:00Z",
  "data": {
    // Event-specific payload
  }
}

Top-Level Fields

FieldTypeDescription
idstringUnique identifier for this event (use for idempotency)
typestringThe event type (e.g., payment.completed)
environmentstringtest or live
createdAtstringISO 8601 timestamp when the event was created
dataobjectEvent-specific payload data

Event Types

payment.completed

Sent when a payment has been successfully received and processed.
{
  "id": "evt_abc123xyz",
  "type": "payment.completed",
  "environment": "live",
  "createdAt": "2024-01-15T14:30:00Z",
  "data": {
    "paymentId": "507f1f77bcf86cd799439011",
    "reference": "checkoutORDER12345678",
    "fiatAmount": "5000.00",
    "currency": "NGN",
    "cryptoAmount": "3.029024",
    "token": "USDC",
    "network": "base",
    "txHash": "0x123abc...",
    "customerEmail": "[email protected]",
    "metadata": {
      "orderId": "12345"
    }
  }
}

payment.failed

Sent when a payment has failed (e.g., expired, underpaid, or processing error).
{
  "id": "evt_def456uvw",
  "type": "payment.failed",
  "environment": "live",
  "createdAt": "2024-01-15T15:00:00Z",
  "data": {
    "paymentId": "507f1f77bcf86cd799439011",
    "reference": "checkoutORDER12345678",
    "fiatAmount": "5000.00",
    "currency": "NGN",
    "status": "expired",
    "errorMessage": "Payment window expired",
    "customerEmail": "[email protected]",
    "metadata": {
      "orderId": "12345"
    }
  }
}

Signature Verification

Every webhook request includes signature headers that you should use to verify the request originated from ChainPal.

Headers

HeaderDescription
X-ChainPal-SignatureHMAC-SHA256 signature in format v1=<signature>
X-ChainPal-TimestampUnix timestamp when the webhook was sent

Verification Process

  1. Extract the timestamp from X-ChainPal-Timestamp
  2. Extract the signature from X-ChainPal-Signature (remove the v1= prefix)
  3. Create the signed content: {timestamp}.{raw_request_body}
  4. Compute HMAC-SHA256 of the signed content using your webhook signing secret
  5. Compare your computed signature with the received signature

Example: Node.js Verification

const crypto = require("crypto");

function verifyWebhookSignature(payload, signature, timestamp, secret) {
  // Create the signed content
  const signedContent = `${timestamp}.${payload}`;

  // Compute HMAC-SHA256
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedContent)
    .digest("hex");

  // Extract signature (remove 'v1=' prefix)
  const receivedSignature = signature.replace("v1=", "");

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(receivedSignature)
  );
}

// Express.js middleware example
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-chainpal-signature"];
  const timestamp = req.headers["x-chainpal-timestamp"];
  const payload = req.body.toString();

  const isValid = verifyWebhookSignature(
    payload,
    signature,
    timestamp,
    process.env.WEBHOOK_SIGNING_SECRET
  );

  if (!isValid) {
    return res.status(401).send("Invalid signature");
  }

  // Process the webhook
  const event = JSON.parse(payload);
  console.log("Received event:", event.type);

  res.status(200).send("OK");
});

Example: Python Verification

import hmac
import hashlib

def verify_webhook_signature(payload: bytes, signature: str, timestamp: str, secret: str) -> bool:
    # Create the signed content
    signed_content = f"{timestamp}.{payload.decode('utf-8')}"

    # Compute HMAC-SHA256
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_content.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Extract signature (remove 'v1=' prefix)
    received_signature = signature.replace('v1=', '')

    # Compare signatures
    return hmac.compare_digest(expected_signature, received_signature)

Example: Go Verification

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
)

func verifyWebhookSignature(payload []byte, signature, timestamp, secret string) bool {
    // Create the signed content
    signedContent := fmt.Sprintf("%s.%s", timestamp, string(payload))

    // Compute HMAC-SHA256
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedContent))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Extract signature (remove 'v1=' prefix)
    receivedSignature := signature[3:] // Remove "v1="

    // Compare signatures
    return hmac.Equal([]byte(expectedSignature), []byte(receivedSignature))
}

Best Practices

1. Respond Quickly

Return a 2xx response as quickly as possible. Process the webhook asynchronously if needed.
app.post("/webhook", (req, res) => {
  // Acknowledge receipt immediately
  res.status(200).send("OK");

  // Process asynchronously
  processWebhookAsync(req.body);
});

2. Handle Duplicates

Use the id field to detect duplicate events. Store processed event IDs and skip duplicates.
const processedEvents = new Set();

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

  processedEvents.add(event.id);
  // Process the event...
}

3. Verify Before Acting

Always verify the payment status via the API before fulfilling an order:
async function handlePaymentCompleted(event) {
  // Verify via API
  const response = await fetch(
    `https://api.chainpal.org/api/v1/payments/${event.data.paymentId}/verify`,
    { headers: { Authorization: `Bearer ${SECRET_KEY}` } }
  );

  const verification = await response.json();

  if (verification.data.paid) {
    // Safe to fulfill the order
    fulfillOrder(event.data.reference);
  }
}

4. Use HTTPS

Always use HTTPS for your webhook endpoint to ensure the payload is encrypted in transit.

5. Validate Timestamps

Reject webhooks with timestamps that are too old (e.g., more than 5 minutes) to prevent replay attacks:
function isTimestampValid(timestamp, toleranceSeconds = 300) {
  const webhookTime = parseInt(timestamp, 10);
  const currentTime = Math.floor(Date.now() / 1000);
  return Math.abs(currentTime - webhookTime) <= toleranceSeconds;
}