Webhooks

CoverPay sends real-time notifications to your server when events occur -- checkout completions, payment results, loan status changes, and more. Configure endpoints in the Dashboard or via the Webhooks API.

How Webhooks Work

1
An event occurs
A customer completes checkout, a payment succeeds or fails, a loan is created.
2
CoverPay sends a POST request
We deliver a signed JSON payload to each subscribed HTTPS endpoint.
3
Your server acknowledges
Respond with any 2xx status within 30 seconds to confirm receipt.
4
Retries if needed
Failed deliveries are retried up to 5 times with exponential backoff.

HTTPS required. Webhook endpoints must use HTTPS. Plaintext HTTP URLs will be rejected when creating or updating an endpoint.

Signature Verification

Every webhook delivery includes an x-coverpay-signature header containing an HMAC-SHA256 signature of the raw request body, computed with your endpoint's signing secret. Always verify this signature before processing the payload to prevent spoofed requests.

The header format is: t=<timestamp>,v1=<signature>

Node.js

javascript
import crypto from 'crypto';

function verifyWebhookSignature(payload, header, secret) {
  const [tPart, vPart] = header.split(',');
  const timestamp = tPart.replace('t=', '');
  const signature = vPart.replace('v1=', '');

  // Reject if timestamp is older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Webhook timestamp too old');
  }

  const signedPayload = `${timestamp}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(payload);
}

// Express.js example
app.post('/webhooks/coverpay', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhookSignature(
      req.body.toString(),
      req.headers['x-coverpay-signature'],
      process.env.COVERPAY_WEBHOOK_SECRET
    );

    switch (event.type) {
      case 'checkout.completed':
        handleCheckoutCompleted(event.data);
        break;
      case 'payment.success':
        handlePaymentSuccess(event.data);
        break;
      case 'payment.failed':
        handlePaymentFailed(event.data);
        break;
    }

    res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook error:', err.message);
    res.status(400).json({ error: err.message });
  }
});

Python

python
import hmac
import hashlib
import time
import json

def verify_webhook_signature(payload: bytes, header: str, secret: str) -> dict:
    parts = header.split(",")
    timestamp = parts[0].replace("t=", "")
    signature = parts[1].replace("v1=", "")

    # Reject if timestamp is older than 5 minutes
    age = int(time.time()) - int(timestamp)
    if age > 300:
        raise ValueError("Webhook timestamp too old")

    signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
    expected = hmac.new(
        secret.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid webhook signature")

    return json.loads(payload)


# Flask example
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhooks/coverpay", methods=["POST"])
def handle_webhook():
    try:
        event = verify_webhook_signature(
            request.data,
            request.headers["x-coverpay-signature"],
            os.environ["COVERPAY_WEBHOOK_SECRET"],
        )

        if event["type"] == "checkout.completed":
            handle_checkout_completed(event["data"])
        elif event["type"] == "payment.success":
            handle_payment_success(event["data"])
        elif event["type"] == "payment.failed":
            handle_payment_failed(event["data"])

        return jsonify(received=True), 200
    except ValueError as e:
        return jsonify(error=str(e)), 400

Event Types

Subscribe to specific events when creating a webhook endpoint, or use * to receive all events.

EventDescription
checkout.completedCustomer completed the BNPL checkout flow and selected a plan.
checkout.abandonedCheckout session expired or the customer exited without completing.
payment.successAn installment payment was successfully processed.
payment.failedAn installment payment failed (card declined, insufficient funds, etc.).
loan.createdA BNPL loan was originated after checkout completion.
loan.completedAll installments have been paid and the loan is closed.
eligibility.failedCustomer did not pass the eligibility pipeline.
refund.initiatedA refund has been initiated for a completed payment.
refund.completedThe refund was successfully processed and funds returned.

Common Payload Fields

Every webhook payload shares a consistent top-level structure.

FieldTypeDescription
idstringUnique event ID (evt_...)
typestringEvent type (e.g. checkout.completed)
createdstringISO 8601 timestamp of when the event occurred
dataobjectEvent-specific payload data
data.customerobjectCustomer details (id, email)
data.amountintegerAmount in cents
data.currencystringISO 4217 currency code

Event Payloads

Full payload examples for each event type.

checkout.completed

json
{
  "id": "evt_ck_1a2b3c4d5e6f",
  "type": "checkout.completed",
  "created": "2026-01-29T14:32:18Z",
  "data": {
    "sessionId": "sess_kl_7a8b9c0d1e2f",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "planType": "pay_in_4",
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 15000,
    "currency": "USD",
    "installments": 4,
    "perInstallment": 3750,
    "paymentToken": "tok_kl_m4n5o6p7q8r9"
  }
}

checkout.abandoned

json
{
  "id": "evt_ca_2b3c4d5e6f7a",
  "type": "checkout.abandoned",
  "created": "2026-01-29T14:47:00Z",
  "data": {
    "sessionId": "sess_af_3c4d5e6f7a8b",
    "orderId": "order_8e7d6c5b",
    "reason": "session_expired",
    "customer": {
      "id": "usr_4d5e6f7a8b9c",
      "email": "mark@example.com"
    },
    "amount": 24900,
    "currency": "USD",
    "lastProvider": "affirm",
    "expiresAt": "2026-01-29T14:45:00Z"
  }
}

payment.success

json
{
  "id": "evt_ps_3c4d5e6f7a8b",
  "type": "payment.success",
  "created": "2026-01-29T15:00:00Z",
  "data": {
    "paymentId": "pmt_5e6f7a8b9c0d",
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "installmentNumber": 1,
    "totalInstallments": 4,
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 3750,
    "currency": "USD",
    "method": "card",
    "paidAt": "2026-01-29T15:00:00Z"
  }
}

payment.failed

json
{
  "id": "evt_pf_4d5e6f7a8b9c",
  "type": "payment.failed",
  "created": "2026-02-12T09:00:00Z",
  "data": {
    "paymentId": "pmt_6f7a8b9c0d1e",
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "installmentNumber": 2,
    "totalInstallments": 4,
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 3750,
    "currency": "USD",
    "failureCode": "card_declined",
    "failureMessage": "Your card was declined. Please update your payment method.",
    "nextRetryAt": "2026-02-14T09:00:00Z"
  }
}

loan.created

json
{
  "id": "evt_lc_5e6f7a8b9c0d",
  "type": "loan.created",
  "created": "2026-01-29T14:32:20Z",
  "data": {
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "planType": "pay_in_4",
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 15000,
    "currency": "USD",
    "installments": 4,
    "perInstallment": 3750,
    "apr": 0,
    "firstPaymentDate": "2026-01-29T00:00:00Z",
    "lastPaymentDate": "2026-04-29T00:00:00Z"
  }
}

loan.completed

json
{
  "id": "evt_ld_6f7a8b9c0d1e",
  "type": "loan.completed",
  "created": "2026-04-29T12:00:00Z",
  "data": {
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 15000,
    "totalPaid": 15000,
    "currency": "USD",
    "installmentsPaid": 4,
    "completedAt": "2026-04-29T12:00:00Z"
  }
}

eligibility.failed

json
{
  "id": "evt_ef_7a8b9c0d1e2f",
  "type": "eligibility.failed",
  "created": "2026-01-29T16:10:45Z",
  "data": {
    "customer": {
      "id": "usr_0d1e2f3a4b5c",
      "email": "bob@example.com"
    },
    "amount": 50000,
    "currency": "USD",
    "failureReasons": ["KYC_PENDING", "TERMS_NOT_ACCEPTED"],
    "checks": {
      "age": { "passed": true },
      "residency": { "passed": true },
      "kyc": { "passed": false, "status": "pending" },
      "sanctions": { "passed": true },
      "terms": { "passed": false }
    }
  }
}

refund.initiated

json
{
  "id": "evt_ri_8b9c0d1e2f3a",
  "type": "refund.initiated",
  "created": "2026-02-05T11:22:00Z",
  "data": {
    "refundId": "ref_9c0d1e2f3a4b",
    "paymentId": "pmt_5e6f7a8b9c0d",
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 15000,
    "currency": "USD",
    "reason": "customer_request",
    "initiatedBy": "merchant"
  }
}

refund.completed

json
{
  "id": "evt_rc_9c0d1e2f3a4b",
  "type": "refund.completed",
  "created": "2026-02-07T09:15:00Z",
  "data": {
    "refundId": "ref_9c0d1e2f3a4b",
    "paymentId": "pmt_5e6f7a8b9c0d",
    "loanId": "loan_4d5e6f7a8b9c",
    "orderId": "order_9f8e7d6c",
    "provider": "klarna",
    "customer": {
      "id": "usr_9a8b7c6d5e4f",
      "email": "jane@example.com"
    },
    "amount": 15000,
    "currency": "USD",
    "refundedAt": "2026-02-07T09:15:00Z"
  }
}

Retry Policy

If your endpoint does not return a 2xx status code within 30 seconds, CoverPay retries the delivery with exponential backoff. After 5 failed attempts, the delivery is marked as failed and no further retries are attempted.

AttemptDelay After FailureCumulative Time
1Immediate0
21 minute1 minute
35 minutes6 minutes
430 minutes36 minutes
52 hours2 hours 36 minutes
Final24 hours~26 hours 36 minutes

What counts as success?

Any HTTP response with a 2xx status code (200, 201, 202, 204, etc.) is treated as a successful delivery. All other status codes, timeouts, and connection errors trigger a retry.

Best Practices

Verify signatures

Always validate the x-coverpay-signature header using your signing secret. Reject requests with missing, expired, or invalid signatures. Use a timing-safe comparison function to prevent timing attacks.

Respond quickly

Return a 2xx response within 5 seconds. If you need to perform slow operations (database writes, external API calls, email sends), acknowledge the webhook immediately and process the event asynchronously using a background job queue.

Use a message queue

For reliability, push incoming webhook payloads onto a durable queue (SQS, Redis, RabbitMQ, etc.) and process them with a separate worker. This decouples receipt from processing and prevents lost events during deploys or outages.

Handle duplicates

Webhook deliveries may occasionally be duplicated (e.g., if your server returns a 2xx but the response is lost in transit). Use the id field to deduplicate events. Store processed event IDs and skip any you have already handled.

Monitor delivery health

Use the Deliveries API or the Dashboard to monitor success rates and response times. If your endpoint consistently fails, CoverPay will automatically disable it and notify you via email.

Test in sandbox

Use the POST /v1/business/webhooks/:id/test endpoint or the Dashboard test button to send simulated events to your endpoint during development. Sandbox webhooks use test signing secrets prefixed with whsec_test_.