Webhooks
Webhooks let you receive real-time HTTP notifications when events occur in your Bhutan Payments account. Instead of polling the API, you register an endpoint URL and subscribe to events — we'll send an HTTP POST to your endpoint whenever something happens.
Setup
Webhook endpoints are configured in the Bhutan Payments dashboard under Settings → Webhooks. From there you can:
- Add a webhook endpoint URL
- Select which event types to subscribe to
- View your webhook signing secret
- Monitor delivery history and failures
- Pause or disable a webhook
How It Works
- You register a webhook endpoint URL in the dashboard
- You subscribe the webhook to one or more event types
- When an event occurs, Bhutan Payments sends an HTTP POST with a signed JSON payload to your endpoint
- Your server verifies the signature and processes the event
- Return a
2xxstatus code to acknowledge receipt
Your App ← HTTP POST ← Bhutan Payments Webhook Dispatcher
Payload Format
Every webhook delivery sends a JSON body with this structure:
{
"id": "evt_a1b2c3d4e5f6g7h8i9j0k1l2m3",
"type": "payment.succeeded",
"api_version": "1.0.0",
"created_at": "2025-04-13T10:30:00Z",
"data": {
},
"metadata": {
"organization_id": "org-uuid",
"environment": "LIVE",
"webhook_id": "webhook-uuid",
"delivery_id": "delivery-uuid"
}
}
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID (prefixed with evt_) |
type | string | Event type identifier (e.g., payment.succeeded) |
api_version | string | API version of the payload schema |
created_at | string | ISO 8601 timestamp of when the event was created |
data | object | Event-specific data (see Event Types below) |
metadata | object | Organization, environment, webhook, and delivery identifiers |
Request Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | BhutanPayments-Webhook/1.0 |
X-Webhook-Signature | HMAC-SHA256 signature for payload verification |
X-Webhook-Timestamp | Unix timestamp of when the request was signed |
X-Webhook-Event-ID | Unique event ID (matches id in payload) |
X-Webhook-Event-Type | Event type (matches type in payload) |
Verifying Signatures
Every webhook is signed using your webhook's secret key with HMAC-SHA256. Always verify signatures before processing events to ensure the request came from Bhutan Payments.
Signature Format
X-Webhook-Signature: t=<unix_timestamp>,v1=<sha256_hash>
The signature is computed over the concatenation of the timestamp and the raw JSON body:
signed_payload = "<timestamp>.<raw_body>"
expected_signature = HMAC-SHA256(webhook_secret, signed_payload)
Verification Examples
Node.js:
const crypto = require('crypto');
function verifyWebhookSignature(payload, signatureHeader, secret) {
const [timestampPart, signaturePart] = signatureHeader.split(',');
const timestamp = timestampPart.replace('t=', '');
const signature = signaturePart.replace('v1=', '');
// Reject requests older than 5 minutes to prevent replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - parseInt(timestamp) > 300) {
throw new Error('Webhook timestamp too old');
}
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid webhook signature');
}
return JSON.parse(payload);
}
// Express.js example
app.post('/webhooks/bhutan-payments', (req, res) => {
try {
const event = verifyWebhookSignature(
req.body, // raw body string
req.headers['x-webhook-signature'],
process.env.BHUTAN_PAYMENTS_WEBHOOK_SECRET
);
switch (event.type) {
case 'payment.succeeded':
// Handle successful payment
break;
case 'payment.failed':
// Handle failed payment
break;
}
res.status(200).send('OK');
} catch (err) {
res.status(400).send('Invalid signature');
}
});
Python:
import hmac
import hashlib
import time
import json
def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> dict:
parts = signature_header.split(',')
timestamp = parts[0].replace('t=', '')
signature = parts[1].replace('v1=', '')
# Reject old requests
if int(time.time()) - int(timestamp) > 300:
raise ValueError('Webhook timestamp too old')
signed_payload = f'{timestamp}.{payload.decode()}'
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError('Invalid webhook signature')
return json.loads(payload)
Go:
func VerifyWebhookSignature(payload []byte, signatureHeader, secret string) error {
parts := strings.SplitN(signatureHeader, ",", 2)
timestamp := strings.TrimPrefix(parts[0], "t=")
signature := strings.TrimPrefix(parts[1], "v1=")
// Check timestamp freshness
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if time.Now().Unix()-ts > 300 {
return errors.New("webhook timestamp too old")
}
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return errors.New("invalid webhook signature")
}
return nil
}
PHP:
function verifyWebhookSignature(string $payload, string $signatureHeader, string $secret): array {
[$timestampPart, $signaturePart] = explode(',', $signatureHeader);
$timestamp = str_replace('t=', '', $timestampPart);
$signature = str_replace('v1=', '', $signaturePart);
// Reject old requests
if (time() - (int)$timestamp > 300) {
throw new Exception('Webhook timestamp too old');
}
$signedPayload = "{$timestamp}.{$payload}";
$expected = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expected, $signature)) {
throw new Exception('Invalid webhook signature');
}
return json_decode($payload, true);
}
Event Types
Payment Events
| Event | Trigger |
|---|---|
payment.succeeded | Payment completed successfully |
payment.failed | Payment attempt failed |
payment.pending | Payment created and awaiting processing |
data object for payment events:
{
"id": "payment-uuid",
"code": "PAY-XK2M9",
"amount": 150.00,
"currency": "USD",
"status": "SUCCEEDED",
"payment_type": "CREDIT_CARD",
"environment": "LIVE",
"platform_fee": 1.50,
"merchant_processing_fee": 4.50,
"net_amount": 144.00,
"created_at": "2025-04-13T10:30:00Z",
"updated_at": "2025-04-13T10:30:05Z",
"card_type": "VISA",
"customer_id": "customer-uuid",
"customer_name": "Jane Doe",
"customer_email": "jane@example.com",
"provider_transaction_id": "pi_3abc...",
"card_brand": "visa",
"card_last_4": "4242",
"invoice_id": null,
"recurring_payment_id": null,
"error_code": null,
"decline_code": null,
"error_message": null
}
For payment.failed events, the error_code, decline_code, and error_message fields are populated with the gateway's decline reason.
Dispute Events
| Event | Trigger |
|---|---|
dispute.created | A new dispute/chargeback was opened |
dispute.updated | Dispute details changed (evidence submitted, status update) |
dispute.won | Dispute resolved in the merchant's favor |
dispute.lost | Dispute resolved in the customer's favor |
Payout Events
| Event | Trigger |
|---|---|
payout.pending | Payout created and queued |
payout.processing | Payout submitted to bank |
payout.completed | Funds deposited to merchant's bank account |
payout.cancelled | Payout was cancelled before processing |
data object for payout events:
{
"id": "payout-uuid",
"code": "PO-XK2M9",
"status": "COMPLETED",
"payout_type": "PAYMENT",
"amount": 144.00,
"currency": "USD",
"payment_id": "payment-uuid",
"payment_code": "PAY-XK2M9",
"bank_account_id": "bank-uuid",
"bank_account_last_4": "6789",
"processed_at": "2025-04-13T12:00:00Z",
"completed_at": "2025-04-15T08:00:00Z",
"created_at": "2025-04-13T10:31:00Z",
"updated_at": "2025-04-15T08:00:00Z"
}
Refund Events
| Event | Trigger |
|---|---|
refund.created | A refund was initiated |
refund.approved | Refund approved by merchant/platform |
refund.completed | Refund processed and funds returned to customer |
refund.declined | Refund request was declined |
Recurring Payment Events
| Event | Trigger |
|---|---|
recurringpayments.created | New subscription created |
recurringpayments.activated | Subscription activated (payment method saved, ready to charge) |
recurringpayments.payment_failed | A scheduled charge attempt failed |
recurringpayments.paused | Subscription paused |
recurringpayments.resumed | Subscription resumed from pause |
recurringpayments.cancelled | Subscription permanently cancelled |
data object for recurring payment events:
{
"id": "recurring-payment-uuid",
"code": "RP-XK2M9",
"status": "ACTIVE",
"amount": 2999,
"currency": "USD",
"interval": "MONTHLY",
"interval_count": 1,
"start_date": "2025-04-13T00:00:00Z",
"next_charge_date": "2025-05-13T00:00:00Z",
"charges_count": 1,
"payment_options": ["CREDIT_CARD", "DEBIT_CARD"],
"customer_email": "jane@example.com",
"customer_name": "Jane Doe",
"description": "Pro Plan - Monthly",
"end_date": null,
"payment_method_id": "pm-uuid",
"created_at": "2025-04-13T10:00:00Z"
}
For recurring payment events that include a payment result (e.g., after a scheduled charge), additional payment fields are included:
{
"payment_id": "payment-uuid",
"payment_status": "SUCCEEDED",
"payment_error_code": null,
"payment_error_message": null
}
Delivery & Retry Behavior
Delivery Expectations
- Webhooks are delivered asynchronously — typically within seconds of the triggering event
- Your endpoint must respond within 30 seconds with a
2xxstatus code - Response bodies are stored (truncated to 64KB) for debugging in the dashboard
Retry Policy
If delivery fails (non-2xx response or timeout), the system retries with exponential backoff:
| Attempt | Approximate Delay |
|---|---|
| 1st retry | ~5 seconds |
| 2nd retry | ~10 seconds |
| 3rd retry | ~20 seconds |
| 4th retry | ~40 seconds |
| 5th retry (final) | ~80 seconds |
Maximum of 5 attempts per delivery. After all retries are exhausted, the delivery is marked as failed.
Auto-Disable
If a webhook accumulates 10 consecutive failed deliveries (across any events), the webhook is automatically disabled to prevent flooding a broken endpoint. You'll need to re-enable it from the dashboard after fixing the issue.
Idempotency
Each delivery includes a unique idempotency key combining the webhook ID, event ID, and event type. Use the X-Webhook-Event-ID header or the id field in the payload to detect and ignore duplicate deliveries.
Best Practices
Return 200 quickly. Process webhook events asynchronously. Acknowledge receipt immediately and handle business logic in a background job. The 30-second timeout is a hard limit.
Verify signatures on every request. Never skip signature verification, even in development. This protects against spoofed webhook deliveries.
Handle duplicate events. Use the event id or the X-Webhook-Event-ID header to deduplicate. Network retries or our retry logic may deliver the same event more than once.
Use HTTPS. Production webhook endpoints must use HTTPS. HTTP endpoints are only accepted in test environments.
Monitor delivery failures. Check the delivery history in the dashboard regularly. A spike in failures may indicate endpoint downtime or an application error.
Process events in order when it matters. Webhook deliveries are not guaranteed to arrive in chronological order. Use the created_at timestamp in the payload if ordering matters for your use case.
Store the raw payload. Log the full request body and headers before processing. This is invaluable for debugging integration issues.