Skip to main content

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​

  1. You register a webhook endpoint URL in the dashboard
  2. You subscribe the webhook to one or more event types
  3. When an event occurs, Bhutan Payments sends an HTTP POST with a signed JSON payload to your endpoint
  4. Your server verifies the signature and processes the event
  5. Return a 2xx status 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"
}
}
FieldTypeDescription
idstringUnique event ID (prefixed with evt_)
typestringEvent type identifier (e.g., payment.succeeded)
api_versionstringAPI version of the payload schema
created_atstringISO 8601 timestamp of when the event was created
dataobjectEvent-specific data (see Event Types below)
metadataobjectOrganization, environment, webhook, and delivery identifiers

Request Headers​

Every webhook request includes these headers:

HeaderDescription
Content-Typeapplication/json
User-AgentBhutanPayments-Webhook/1.0
X-Webhook-SignatureHMAC-SHA256 signature for payload verification
X-Webhook-TimestampUnix timestamp of when the request was signed
X-Webhook-Event-IDUnique event ID (matches id in payload)
X-Webhook-Event-TypeEvent 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​

EventTrigger
payment.succeededPayment completed successfully
payment.failedPayment attempt failed
payment.pendingPayment 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​

EventTrigger
dispute.createdA new dispute/chargeback was opened
dispute.updatedDispute details changed (evidence submitted, status update)
dispute.wonDispute resolved in the merchant's favor
dispute.lostDispute resolved in the customer's favor

Payout Events​

EventTrigger
payout.pendingPayout created and queued
payout.processingPayout submitted to bank
payout.completedFunds deposited to merchant's bank account
payout.cancelledPayout 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​

EventTrigger
refund.createdA refund was initiated
refund.approvedRefund approved by merchant/platform
refund.completedRefund processed and funds returned to customer
refund.declinedRefund request was declined

Recurring Payment Events​

EventTrigger
recurringpayments.createdNew subscription created
recurringpayments.activatedSubscription activated (payment method saved, ready to charge)
recurringpayments.payment_failedA scheduled charge attempt failed
recurringpayments.pausedSubscription paused
recurringpayments.resumedSubscription resumed from pause
recurringpayments.cancelledSubscription 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 2xx status 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:

AttemptApproximate 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.