Appearance
PayBridge -- Webhook Documentation
This document covers how PayBridge forwards payment processor events to your application and how to handle them securely.
Overview
PayBridge acts as a webhook intermediary between payment processors (Clearent/XplorPay) and your application:
Processor (Clearent)
|
| processor-specific event
v
PayBridge /webhooks/clearent
|
| 1. Verify processor signature
| 2. Check timestamp freshness (5-min window)
| 3. Deduplicate by gateway event ID
| 4. Update internal DB state
| 5. Map to standardized event type
| 6. Sign and forward to your webhook URL
v
Your Application (registered webhook_url)Your application receives standardized event types regardless of which processor originated the event. You never need to handle processor-specific event formats.
Event Types
PayBridge emits four categories of standardized events: payment, merchant onboarding, checkout, and document events. Your handler receives the same standardized shapes regardless of which processor originated them.
Payment Events
| Event Type | When It Fires | Trigger Conditions |
|---|---|---|
payment.completed | A payment has been settled or approved by the processor | Clearent: transaction.settled or transaction.approved |
payment.declined | A payment was declined by the processor | Clearent: transaction.declined |
payment.refunded | A refund has been processed (full or partial) | Clearent: transaction.refunded |
Refund coverage.
payment.refundedcovers both full and partial refunds — a partial refund reports only the refunded portion, a full refund the whole amount. Each refund is recorded as its own transaction carrying a negativeamount. To correlate a refund to its original sale, use theRefundResponsereturned byPOST /payments/{id}/refund— itsidis the refund and itspayment_idis the original sale. The webhookpayment_idis processor-dependent and is not a reliable link back to the sale (see the event detail below).Not currently emitted. PayBridge does not emit separate
capture,void, ordispute/chargebackevents — the processor integration does not surface them as distinct lifecycle callbacks today. A successful sale is reported viapayment.completed(which may arrive more than once — see its details below). If you need capture/void/dispute handling, reconcile againstGET /payments/{payment_id}and the processor's own reporting; do not wait for a webhook that is not sent.
Merchant Onboarding Events
| Event Type | When It Fires |
|---|---|
merchant.approved | The onboarding application was approved; the merchant can process payments |
merchant.boarded | The merchant is fully boarded / live at the processor |
merchant.pended | The application was pended by the processor |
merchant.manual_review | The application entered manual review (corresponds to pending_review status) |
merchant.rejected | The onboarding application was denied |
Checkout Events
| Event Type | When It Fires |
|---|---|
checkout.completed | A hosted checkout session payment succeeded |
Document Events
Fired while the processor's underwriting requests or reviews supporting documents for a merchant.
| Event Type | When It Fires |
|---|---|
document.required | The processor requires a document to continue underwriting |
document.uploaded | A required document was uploaded |
document.accepted | An uploaded document was accepted |
document.rejected | An uploaded document was rejected (re-upload needed) |
Webhook Envelope
All outbound webhooks use a consistent JSON envelope. The structure varies slightly between payment events and merchant events.
Payment Event Envelope
json
{
"event_type": "payment.completed",
"webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"payment_id": "a1b2c3d4-5678-9abc-def0-1234567890ab",
"merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
"amount": 75000,
"status": "posted",
"timestamp": "2026-03-10T12:15:30.123456+00:00"
}| Field | Type | Description |
|---|---|---|
event_type | string | One of: payment.completed, payment.declined, payment.refunded |
webhook_id | string (UUID) | Unique delivery ID. Use for deduplication. |
payment_id | string (UUID) | PayBridge transaction ID |
merchant_id | string (UUID) | Merchant that owns this transaction |
amount | int | Amount in minor units (cents). 75000 = $750.00 |
status | string | Transaction status after this event (posted, declined, refunded) |
timestamp | string (ISO 8601) | When this webhook was generated |
Merchant Event Envelope
json
{
"event_type": "merchant.approved",
"webhook_id": "a23bc45d-67ef-8901-a234-5b6c7d8e9f01",
"merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
"processor_type": "clearent",
"processor_merchant_id": "CLR_12345",
"onboarding_status": "approved",
"timestamp": "2026-03-11T14:00:00.000000+00:00"
}| Field | Type | Description |
|---|---|---|
event_type | string | One of: merchant.approved, merchant.boarded, merchant.pended, merchant.manual_review, merchant.rejected |
webhook_id | string (UUID) | Unique delivery ID. Use for deduplication. |
merchant_id | string (UUID) | Merchant whose onboarding status changed |
processor_type | string | Processor type: clearent |
processor_merchant_id | string | External merchant ID at the processor |
onboarding_status | string | The merchant's new onboarding status (e.g. approved, boarded, pending_review, rejected) |
rejection_reason | string | Optional reason when the event is merchant.rejected |
timestamp | string (ISO 8601) | When this webhook was generated |
Document Event Envelope
Document-lifecycle events (document.*) carry document identifiers instead of the payment/onboarding fields.
json
{
"event_type": "document.rejected",
"webhook_id": "c45de67f-8901-2bcd-ef34-567890123456",
"document_id": "d1e2f3a4-5678-90bc-def1-234567890abc",
"merchant_id": "b2c3d4e5-6789-0abc-def1-234567890abc",
"document_type": "bank_statement",
"status": "rejected",
"rejection_reason": "Statement is older than 90 days",
"timestamp": "2026-03-11T14:00:00.000000+00:00"
}| Field | Type | Description |
|---|---|---|
event_type | string | One of: document.required, document.uploaded, document.accepted, document.rejected |
webhook_id | string (UUID) | Unique delivery ID. Use for deduplication. |
document_id | string (UUID) | The document this event concerns |
merchant_id | string (UUID) | Merchant the document belongs to |
document_type | string | The kind of document (processor-defined, e.g. bank_statement) |
status | string | The document's status after this event |
description | string | Optional human-readable detail |
rejection_reason | string | Optional reason, present on document.rejected |
timestamp | string (ISO 8601) | When this webhook was generated |
Event Details
payment.completed
Fired when a payment is settled or approved by the processor. This is the primary success signal -- update your order status, send receipts, and fulfill the order when you receive this event.
Envelope example:
json
{
"event_type": "payment.completed",
"webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"payment_id": "txn_abc123",
"merchant_id": "a1b2c3d4-...",
"amount": 75000,
"status": "posted",
"timestamp": "2026-03-10T12:15:30.123456+00:00"
}Key details:
- The
amountfield is always in minor units (cents).75000= $750.00. - The
statusfield will bepostedfor both settled and approved events. - A single sale can produce more than one
payment.completed. Clearent sendstransaction.approvedandtransaction.settledas distinct events, and both map topayment.completed. They carry the samepayment_id(the same underlying transaction) but differentwebhook_ids, sowebhook_id-based dedup does not collapse them — treatpayment.completedidempotently perpayment_id. - For failover payments (where the primary processor failed and the secondary succeeded), the webhook fires normally. The
payment_idcorresponds to the same transaction you originally created. You can checkGET /payments/{payment_id}to seefailover_from_idif you need to know failover occurred. - This event maps from Clearent
transaction.settled/transaction.approved.
Recommended actions:
- Mark the order as paid in your system.
- Send a payment confirmation email to the customer.
- Begin fulfillment (ship product, activate service, etc.).
payment.declined
Fired when a payment is declined by the processor. Decline reasons include insufficient funds, invalid card, expired card, and processor-specific codes.
Envelope example:
json
{
"event_type": "payment.declined",
"webhook_id": "d12e34f5-6789-0abc-def1-234567890abc",
"payment_id": "txn_def456",
"merchant_id": "a1b2c3d4-...",
"amount": 75000,
"status": "declined",
"timestamp": "2026-03-10T12:16:00.000000+00:00"
}Key details:
- The
statusfield will bedeclined. - The
amountfield reflects the attempted charge amount in cents. - Decline reasons are not included in the webhook envelope. Query
GET /payments/{payment_id}to get the full decline details if needed. - Terminal declines (insufficient funds, stolen card, expired card) are not retried by the failover engine. Only transient processor errors trigger failover.
Recommended actions:
- Notify the customer that their payment was declined.
- Prompt them to update their payment method or try a different card.
- Update your order status to "payment failed" or similar.
payment.refunded
Fired when a refund is processed by the processor. This covers both full and partial refunds.
Envelope example:
json
{
"event_type": "payment.refunded",
"webhook_id": "e23f45a6-7890-1bcd-ef23-456789012345",
"payment_id": "txn_abc123",
"merchant_id": "a1b2c3d4-...",
"amount": -25000,
"status": "refunded",
"timestamp": "2026-03-12T11:00:00.000000+00:00"
}Key details:
- The
amountfield is the refund amount as a negative number in cents.-25000= $250.00 refunded. - To link a refund back to its original sale, use the
RefundResponsefromPOST /payments/{id}/refund(itsidis the refund, itspayment_idthe original). The webhookpayment_idis processor-dependent — on Aptexx it is the refund's own id, while on Clearent the refund currently shares the original sale's processor id — so do not rely on it to correlate. - For partial refunds, the
amountreflects only the refunded portion; each partial refund is recorded as its own transaction. - For full refunds, the
amountequals the negative of the original payment amount. - The refund is routed to the same processor that handled the original payment, including failover scenarios. If the original payment failed over to the secondary processor, the refund goes to the secondary processor.
Recommended actions:
- Update your order to reflect the refund (partial or full).
- Notify the customer that their refund has been processed.
- Adjust any revenue/accounting records.
merchant.approved
Fired when a merchant's onboarding application is approved by the processor. The merchant can now process payments.
Envelope example:
json
{
"event_type": "merchant.approved",
"webhook_id": "a23bc45d-67ef-8901-a234-5b6c7d8e9f01",
"merchant_id": "a1b2c3d4-...",
"processor_type": "clearent",
"processor_merchant_id": "CLR_12345",
"onboarding_status": "approved",
"timestamp": "2026-03-11T14:00:00.000000+00:00"
}Recommended actions:
- Enable payment processing in your UI for this merchant.
- Notify the merchant that their account is active.
- Begin accepting payments through this merchant.
merchant.rejected
Fired when a merchant's onboarding application is denied by the processor.
Envelope example:
json
{
"event_type": "merchant.rejected",
"webhook_id": "b34cd56e-78f0-1234-a567-890123456789",
"merchant_id": "a1b2c3d4-...",
"processor_type": "clearent",
"processor_merchant_id": "CLR_12345",
"onboarding_status": "rejected",
"rejection_reason": "Failed underwriting review",
"timestamp": "2026-03-11T16:00:00.000000+00:00"
}Recommended actions:
- Notify the merchant that their application was denied.
- Suggest they review their submitted information and re-apply.
- Direct them to your support team for assistance.
Notes:
rejection_reasonis included when PayBridge has a reason from the processor or from an admin override.- If a processor webhook is delayed or missed, the same merchant event envelope may be generated by the onboarding reconcile path after polling the processor status.
checkout.completed
Fired when a customer completes payment on a hosted checkout session. This is the primary event for integrations that use the hosted checkout flow (redirect the customer to checkout_url).
Envelope example:
json
{
"event_type": "checkout.completed",
"webhook_id": "c45de67f-8901-2bcd-ef34-567890123456",
"session_id": "session-uuid",
"payment_id": "payment-uuid",
"merchant_id": "a1b2c3d4-...",
"amount": 75000,
"currency": "USD",
"status": "posted",
"timestamp": "2026-03-26T12:02:30.000000+00:00"
}| Field | Type | Description |
|---|---|---|
event_type | string | Always checkout.completed |
webhook_id | string (UUID) | Unique delivery ID. Use for deduplication. |
session_id | string (UUID) | The checkout session ID |
payment_id | string (UUID) | PayBridge transaction ID |
merchant_id | string (UUID) | Merchant that owns this session |
amount | int | Amount in minor units (cents). 75000 = $750.00 |
currency | string | 3-letter ISO currency code (e.g. USD) |
status | string | Transaction status (posted) |
timestamp | string | ISO 8601 UTC timestamp |
Recommended actions:
- Verify the
session_idmatches the order you created the session for. - Confirm the
amountmatches the expected order total. - Mark the order as paid and fulfil it.
- You can also verify via
GET /checkout/sessions/{session_id}— always confirm from your backend, do not trust redirect query parameters alone.
Signature Verification
Every outbound webhook includes an X-PayBridge-Signature header containing an HMAC-SHA256 signature of the request body. Always verify this signature to ensure the webhook was sent by PayBridge and was not tampered with.
Signature Format
X-PayBridge-Signature: sha256=<hex-encoded HMAC-SHA256>The HMAC is computed over the raw JSON request body bytes using your app's webhook_secret (returned when your consumer app is provisioned via POST /admin/apps).
Verification Steps
- Read the raw request body as bytes (do NOT parse JSON first).
- Compute HMAC-SHA256 of the raw body using your
webhook_secretas the key. - Prepend
sha256=to your computed hex digest. - Compare your computed signature with the
X-PayBridge-Signatureheader using a constant-time comparison function (to prevent timing attacks). - Reject the request if the signatures do not match.
Python Example
python
import hashlib
import hmac
import json
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = "your-webhook-secret-from-provisioning"
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify X-PayBridge-Signature header."""
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/api/pb-webhooks")
async def handle_pb_webhook(
request: Request,
x_paybridge_signature: str = Header(..., alias="X-PayBridge-Signature"),
):
body = await request.body()
if not verify_signature(body, x_paybridge_signature, WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
event_type = payload["event_type"]
webhook_id = payload.get("webhook_id")
# Deduplicate: check if you have already processed this webhook_id
# if already_processed(webhook_id):
# return {"status": "ok"}
if event_type == "payment.completed":
handle_payment_completed(payload)
elif event_type == "payment.declined":
handle_payment_declined(payload)
elif event_type == "payment.refunded":
handle_payment_refunded(payload)
elif event_type == "merchant.approved":
handle_merchant_approved(payload)
elif event_type == "merchant.rejected":
handle_merchant_rejected(payload)
# Mark this webhook_id as processed
# mark_processed(webhook_id)
return {"status": "ok"}Node.js Example
javascript
const express = require("express");
const crypto = require("crypto");
const app = express();
// Capture raw body for signature verification
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
const WEBHOOK_SECRET = "your-webhook-secret-from-provisioning";
function verifySignature(rawBody, signature, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
} catch {
return false;
}
}
app.post("/api/pb-webhooks", (req, res) => {
const signature = req.headers["x-paybridge-signature"];
if (!signature || !verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
const { event_type, webhook_id, payment_id, merchant_id, amount } = req.body;
// Deduplicate: check if you have already processed this webhook_id
// if (alreadyProcessed(webhook_id)) return res.json({ status: "ok" });
switch (event_type) {
case "payment.completed":
console.log(`Payment ${payment_id} completed: $${(amount / 100).toFixed(2)}`);
break;
case "payment.declined":
console.log(`Payment ${payment_id} declined`);
break;
case "payment.refunded":
console.log(`Refund on ${payment_id}: $${(Math.abs(amount) / 100).toFixed(2)}`);
break;
case "merchant.approved":
console.log(`Merchant ${merchant_id} approved`);
break;
case "merchant.rejected":
console.log(`Merchant ${merchant_id} rejected`);
break;
default:
console.log(`Unknown event: ${event_type}`);
}
// Mark this webhook_id as processed
// markProcessed(webhook_id);
res.json({ status: "ok" });
});
app.listen(3000, () => console.log("Webhook server listening on port 3000"));PHP Example
php
<?php
$webhookSecret = 'your-webhook-secret-from-provisioning';
// Read the raw request body
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYBRIDGE_SIGNATURE'] ?? '';
// Verify signature
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
$eventType = $payload['event_type'] ?? '';
$webhookId = $payload['webhook_id'] ?? '';
// Deduplicate: check if you have already processed this webhook_id
// if (alreadyProcessed($webhookId)) {
// http_response_code(200);
// echo json_encode(['status' => 'ok']);
// exit;
// }
switch ($eventType) {
case 'payment.completed':
$paymentId = $payload['payment_id'];
$amount = $payload['amount'];
// Update order status, send receipt
error_log("Payment {$paymentId} completed: $" . number_format($amount / 100, 2));
break;
case 'payment.declined':
$paymentId = $payload['payment_id'];
// Notify customer
error_log("Payment {$paymentId} declined");
break;
case 'payment.refunded':
$paymentId = $payload['payment_id'];
$amount = abs($payload['amount']);
// Update records
error_log("Payment {$paymentId} refunded: $" . number_format($amount / 100, 2));
break;
case 'merchant.approved':
$merchantId = $payload['merchant_id'];
// Enable payment processing
error_log("Merchant {$merchantId} approved");
break;
case 'merchant.rejected':
$merchantId = $payload['merchant_id'];
// Notify merchant
error_log("Merchant {$merchantId} rejected");
break;
default:
error_log("Unknown event: {$eventType}");
}
// Mark this webhook_id as processed
// markProcessed($webhookId);
http_response_code(200);
echo json_encode(['status' => 'ok']);Retry Behavior
PayBridge uses a best-effort delivery model. Failed forwards can be retried, but retries are admin-triggered, not automatic (see below). All four event families — merchant.*, payment.*, checkout.completed, and document.* — persist a delivery record, so a failed forward is visible to support and can be retried.
Delivery Mechanics
| Aspect | Detail |
|---|---|
| Timeout | 10 seconds per delivery attempt |
| Max attempts | 5 total (1 initial + 4 retries) |
| Retry trigger | A network error or timeout only. A non-2xx HTTP response is logged and treated as delivered — it is not retried, so always return 2xx. |
| Retry mechanism | Admin-triggered: POST /admin/retry-webhooks (bulk, forwards under the attempt cap) or POST /admin/webhooks/{id}/requeue (single event, re-arms an exhausted forward) — there is no automatic scheduler |
| Coverage | merchant.*, payment.*, checkout.completed, and document.* all record forward_failed_at on failure and are retryable. Status polling remains the recommended fallback for confirmation. |
How Retries Work
- Initial delivery: When an event occurs (a processor callback, a completed checkout, or a document lifecycle change), PayBridge immediately attempts to forward it to your registered
webhook_url. - Failure tracking: Only a network error or timeout marks the webhook event with
forward_failed_atand increments the attempt count. A non-2xx HTTP response does not — it is logged and treated as delivered, so it is never retried. - Retry execution: Retries are triggered by calling
POST /admin/retry-webhooks(bulk, respects the backoff schedule below). This queries all failed events with fewer than 5 attempts and retries them. - Permanent failure: After 5 total attempts, automatic bulk retries stop and the event is considered permanently failed. It can still be re-armed and delivered on request via
POST /admin/webhooks/{id}/requeue, which resets the attempt counter — used, for example, to replay events lost to a delivery bug. - SSRF re-validation: On every retry, the webhook URL is re-validated against private/internal networks. If a previously safe URL now resolves to an internal IP, the retry is permanently blocked.
Retry Schedule
The exponential backoff below is enforced in code: a retry attempted before an event's window has elapsed is skipped (recorded as skipped_backoff), never sent early. What is not automatic is the trigger — retries are batch-processed only when POST /admin/retry-webhooks runs (the PayBridge operations team runs it periodically, or it can be configured as a cron job). So the windows below are the minimum delay before an attempt becomes eligible; the actual send also waits for the next admin run.
| Attempt | Backoff before eligible (enforced) |
|---|---|
| 1 (initial) | Immediate |
| 2 | 30 seconds after the initial failure |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After attempt 5 the event is permanently failed for bulk retries; it can still be re-armed via POST /admin/webhooks/{id}/requeue (which resets the attempt counter and bypasses the backoff gate for that one deliberate send).
Fallback: Status Polling
Since webhook delivery is best-effort, always implement status polling as a fallback:
- Payment status:
GET /payments/{payment_id}to check if a payment has been settled/declined. - Onboarding status:
GET /merchants/{merchant_id}/onboard/statusto check if a merchant has been approved/rejected. - Checkout sessions:
GET /checkout/sessions/{session_id}to check if a session has been completed.
Event Ordering
PayBridge does not guarantee that webhooks arrive in the order the underlying events occurred. Delivery is best-effort and per-event: each event is forwarded independently, and a failed forward is retried on its own backoff curve (up to 2 hours between attempts). A payment.completed that failed its first send and a later payment.refunded that succeeded immediately can therefore reach your endpoint out of order, and the same is true across the merchant lifecycle (merchant.pended → merchant.approved).
Design your handler so ordering does not matter:
- Treat each webhook as a signal to re-read state, not as the state itself. On any payment event, the authoritative status is
GET /payments/{payment_id}; on any merchant event, it isGET /merchants/{merchant_id}/onboard/status. The webhook tells you when to check; the API tells you what is true now. - Use the
timestampfield to detect stale events. It reflects when PayBridge generated the webhook. If you receive an event older than the state you already recorded for thatpayment_id/merchant_id, treat it as informational and do not roll your state backward. - Do not assume a terminal event is last. A retried non-terminal event can arrive after a terminal one; gate irreversible actions (refund accounting, account deactivation) on the API status, not on webhook arrival order.
Idempotency and Deduplication
Inbound Deduplication (Processor to PayBridge)
PayBridge deduplicates inbound processor webhooks using a composite key of gateway_id:event_type. This means:
- The same transaction receiving both
transaction.approvedandtransaction.settledevents are treated as distinct (different event types). - A duplicate
transaction.settledevent for the same gateway transaction ID is silently dropped. - Deduplication uses
SELECT ... FOR UPDATEto prevent race conditions from concurrent webhook deliveries.
Outbound Deduplication (PayBridge to Your App)
Every outbound webhook includes a webhook_id field (a UUID). Use this as a deduplication key on your side:
- When you receive a webhook, check if you have already processed this
webhook_id. - If yes, return
200 OKwithout reprocessing. - If no, process the event and store the
webhook_idas processed.
This protects against the case where PayBridge delivers the same webhook multiple times (for example, if your server returned 200 but PayBridge did not receive the response before timing out).
Recommended implementation:
sql
-- Example (MySQL — adapt syntax for your database)
CREATE TABLE processed_webhooks (
webhook_id CHAR(36) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Before processing:
INSERT INTO processed_webhooks (webhook_id) VALUES ($1)
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id;
-- If no row returned, the webhook was already processedBest Practices
1. Respond Quickly
Return a 2xx status code as fast as possible (ideally within 1-2 seconds). Do not perform heavy processing synchronously in your webhook handler. Instead:
- Validate the signature.
- Store the event in a queue or database.
- Return
200 OK. - Process the event asynchronously (background worker, queue consumer, etc.).
2. Handle All Event Types
Your handler should gracefully accept any event_type, including ones you do not recognize. Return 200 OK for unknown events so that PayBridge does not retry them. This future-proofs your integration against new event types.
3. Verify Signatures in Production
Always verify the X-PayBridge-Signature header. Without verification, an attacker could forge webhooks to your endpoint and trigger fraudulent order fulfillment.
4. Use HTTPS
Your webhook endpoint must use HTTPS in production. PayBridge validates webhook URLs against internal/private networks (SSRF protection) and will reject URLs targeting 127.0.0.1, 10.x.x.x, 192.168.x.x, and other private ranges.
5. Implement Idempotent Handling
Always use the webhook_id to deduplicate. Processing the same event twice could result in sending duplicate emails, double-crediting refunds, or other unintended side effects.
6. Validate Merchant Ownership
When processing a webhook, verify that the merchant_id in the payload belongs to your application. This provides defense-in-depth against webhook URL misconfiguration.
7. Monitor for Missing Webhooks
Implement a reconciliation check that periodically polls the status APIs to catch events that may have been missed due to webhook delivery failures:
python
# Example: Check for payments that completed but we never got a webhook for
import httpx
async def reconcile_payments():
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/payments/merchants/{MERCHANT_ID}/transactions",
headers={"X-API-Key": API_KEY},
params={"status": "posted", "limit": 100},
)
transactions = response.json()["items"]
for txn in transactions:
if not is_webhook_received(txn["id"]):
# Process this payment manually
handle_payment_completed_manually(txn)Setting Up Your Webhook URL
Your webhook URL is configured when your consumer app is provisioned via POST /admin/apps (include the webhook_url field). To update it later (requires admin JWT from POST /admin/auth/login):
bash
curl -X PUT "$BASE_URL/admin/apps/$APP_ID/webhook-url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_JWT" \
-d '{"webhook_url": "https://your-domain.com/api/pb-webhooks"}'Requirements for webhook URLs:
- Must be a valid HTTPS URL (HTTP allowed only in development).
- Must resolve to a public IP address (not
127.0.0.1,10.x.x.x,192.168.x.x, or other private ranges). - Must respond within 10 seconds.
- Must return a
2xxstatus code to acknowledge receipt.
Testing Your Webhook Handler
Use POST /webhooks/test to send a test event to your registered webhook URL. This verifies your handler is correctly receiving and validating HMAC-signed events before you go live.
bash
curl -X POST "$BASE_URL/webhooks/test" \
-H "X-API-Key: $API_KEY"The test event uses event_type: "test" and includes a dummy merchant_id. Your handler should accept it and return 2xx.
Response:
json
{
"delivered": true,
"status_code": 200,
"webhook_url": "https://your-domain.com/api/pb-webhooks"
}If delivery fails, the response includes an error field with the failure reason.
Webhook Security Summary
| Protection | Implementation |
|---|---|
| Authenticity | HMAC-SHA256 signature in X-PayBridge-Signature header |
| Integrity | Signature covers the entire raw request body |
| Replay prevention | Inbound processor webhook freshness check (5-minute window) |
| Deduplication | Composite gateway_id:event_type key with SELECT FOR UPDATE |
| SSRF prevention | Webhook URL validated against private/internal networks on every delivery and retry |
| DNS rebinding prevention | After URL validation, HTTP connection is pinned to the resolved IP |
| Timing attack prevention | Constant-time signature comparison (hmac.compare_digest / crypto.timingSafeEqual / hash_equals) |