Validate Requests

This document explains how to verify webhook requests from QuickPay using public key cryptography to ensure the authenticity and integrity of webhook data.

Overview

QuickPay uses private key cryptography to sign webhook requests, ensuring that:

  • Authenticity: The webhook is genuinely from QuickPay
  • Integrity: The data hasn't been tampered with during transmission
  • Non-repudiation: QuickPay cannot deny sending the webhook

To validate the signature in Production workspaces, please use the public key below:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----

Verification Process

1. Extract Headers

Extract the following headers from the webhook request:

HeaderDescription
X-Webhook-SignatureBase64-encoded signature (signed with QuickPay's private key)
X-Webhook-TimestampUnix timestamp of the webhook
X-Webhook-Trace-IDUnique trace ID for idempotency

2. Create Payload String

Use the raw JSON body of the webhook request directly:

{request_body}

Where:

  • request_body is the raw JSON body of the webhook request

3. Verify Signature

Use QuickPay's public key to verify the signature (which was created using QuickPay's private key):

Code Example

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, publicKey) {
  try {
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(payload);
    return verifier.verify(publicKey, signature, 'base64');
  } catch (error) {
    console.error('Signature verification failed:', error);
    return false;
  }
}
function verifyWebhookSignature($payload, $signature, $publicKey) {
    try {
        $publicKeyResource = openssl_pkey_get_public($publicKey);
        if (!$publicKeyResource) {
            throw new Exception('Invalid public key');
        }
        
        $signatureBytes = base64_decode($signature);
        $result = openssl_verify($payload, $signatureBytes, $publicKeyResource, OPENSSL_ALGO_SHA256);
        
        openssl_free_key($publicKeyResource);
        
        return $result === 1;
    } catch (Exception $e) {
        error_log("Signature verification failed: " . $e->getMessage());
        return false;
    }
}
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.nio.charset.StandardCharsets;

public class WebhookVerifier {
    private final PublicKey publicKey;
    
    public WebhookVerifier(String publicKeyPem) throws Exception {
        // Remove header and footer, and decode
        String publicKeyPEM = publicKeyPem
            .replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replaceAll("\\s", "");
        
        byte[] encoded = Base64.getDecoder().decode(publicKeyPEM);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
        this.publicKey = keyFactory.generatePublic(keySpec);
    }
    
    public boolean verifySignature(String payload, String signature) {
        try {
            Signature sig = Signature.getInstance("SHA256withRSA");
            sig.initVerify(publicKey);
            sig.update(payload.getBytes(StandardCharsets.UTF_8));
            
            byte[] signatureBytes = Base64.getDecoder().decode(signature);
            return sig.verify(signatureBytes);
        } catch (Exception e) {
            System.err.println("Signature verification failed: " + e.getMessage());
            return false;
        }
    }
}

Webhook Response Requirements

HTTP Status Codes

Status CodeDescriptionQuickPay Behavior
200SuccessStop retrying, webhook processed successfully
4xxClient ErrorStop retrying, webhook will not be retried
5xxServer ErrorRetry webhook with exponential backoff

Response Format

Always return a JSON response with a success field:

{
  "success": true
}

QuickPay Retry Mechanism

Retry Schedule

If your webhook endpoint returns a 5xx status code or fails to respond, QuickPay will retry with the following schedule:

AttemptDelayTotal Time
1stImmediate0s
2nd30 seconds30s
3rd1 minute1m 30s
4th2 minutes3m 30s
5th4 minutes7m 30s
6th8 minutes15m 30s
7th16 minutes31m 30s
8th32 minutes1h 3m 30s
9th64 minutes2h 7m 30s
10th128 minutes4h 15m 30s

Maximum Retries: 10 attempts over approximately 4 hours

Retry Headers

Each retry will include the same headers as the original request, plus:

  • X-Webhook-Retry-Count: Current retry attempt number
  • X-Webhook-Retry-Attempt: Timestamp of this retry attempt

Security Best Practices

1. Always Verify Signatures

  • Never process webhooks without signature verification
  • Use the official QuickPay public key (to verify signatures created with QuickPay's private key)
  • Implement proper error handling for verification failures

2. Check Timestamp

  • Verify the timestamp is recent (within 5 minutes)
  • Prevent replay attacks

3. Implement Idempotency

  • Use the X-Webhook-Trace-ID for idempotency
  • Store processed trace IDs to prevent duplicates

4. Secure Storage

  • Store QuickPay's public key securely
  • Rotate keys when necessary
  • Use environment variables for sensitive data

5. Error Handling

  • Log verification failures
  • Return appropriate HTTP status codes
  • Implement monitoring and alerting

Testing

Test with cURL

# Test webhook endpoint
curl -X POST "https://your-domain.com/webhook" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: test_signature" \
  -H "X-Webhook-Timestamp: 1642239000" \
  -H "X-Webhook-Trace-ID: test_trace_id" \
  -d '{
    "event_type": "payment.created",
    "event_id": "evt_test_1234567890",
    "timestamp": "2024-01-15T10:30:00Z",
    "data": {
      "payment_id": "pay_test_1234567890",
      "amount": "100.00",
      "currency": "USD",
      "status": "pending"
    }
  }'