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
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
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
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)), 400Event Types
Subscribe to specific events when creating a webhook endpoint, or use * to receive all events.
| Event | Description |
|---|---|
| checkout.completed | Customer completed the BNPL checkout flow and selected a plan. |
| checkout.abandoned | Checkout session expired or the customer exited without completing. |
| payment.success | An installment payment was successfully processed. |
| payment.failed | An installment payment failed (card declined, insufficient funds, etc.). |
| loan.created | A BNPL loan was originated after checkout completion. |
| loan.completed | All installments have been paid and the loan is closed. |
| eligibility.failed | Customer did not pass the eligibility pipeline. |
| refund.initiated | A refund has been initiated for a completed payment. |
| refund.completed | The refund was successfully processed and funds returned. |
Common Payload Fields
Every webhook payload shares a consistent top-level structure.
| Field | Type | Description |
|---|---|---|
| id | string | Unique event ID (evt_...) |
| type | string | Event type (e.g. checkout.completed) |
| created | string | ISO 8601 timestamp of when the event occurred |
| data | object | Event-specific payload data |
| data.customer | object | Customer details (id, email) |
| data.amount | integer | Amount in cents |
| data.currency | string | ISO 4217 currency code |
Event Payloads
Full payload examples for each event type.
checkout.completed
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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.
| Attempt | Delay After Failure | Cumulative Time |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | 2 hours 36 minutes |
| Final | 24 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_.