Webhooks Deep Dive
Advanced guide to implementing production-ready webhook handling.
Overview
Webhooks allow Koywe to send real-time notifications about order status changes to your server.
Why Use Webhooks?
Benefits:
- Real-time order status updates
- No polling required
- Scalable architecture
- Reliable delivery with retries
Webhook Signature Verification
Critical: Always verify webhook signatures to ensure authenticity.
Verification Process
const crypto = require('crypto');
const express = require('express');
app.post('/webhooks/koywe',
express.raw({ type: 'application/json' }), // MUST use raw body
(req, res) => {
// 1. Get signature from header
const signature = req.headers['koywe-signature'];
const secret = process.env.KOYWE_WEBHOOK_SECRET;
// 2. Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(req.body) // Raw body (Buffer)
.digest('hex');
// 3. Compare signatures
if (signature !== expectedSignature) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// 4. Signature valid, parse event
const event = JSON.parse(req.body);
// 5. Process event
await processWebhook(event);
// 6. Respond quickly
res.status(200).send('OK');
}
);import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/koywe', methods=['POST'])
def webhook():
# 1. Get signature
signature = request.headers.get('Koywe-Signature')
secret = os.environ['KOYWE_WEBHOOK_SECRET'].encode()
# 2. Calculate expected signature
expected_signature = hmac.new(
secret,
request.data,
hashlib.sha256
).hexdigest()
# 3. Verify
if signature != expected_signature:
return 'Invalid signature', 401
# 4. Parse event
event = json.loads(request.data)
# 5. Process
process_webhook(event)
# 6. Respond
return 'OK', 200Common Mistakes:
- Using
express.json()instead ofexpress.raw()- this modifies the body - Using parsed JSON for signature calculation - must use raw body
- Wrong secret - verify you’re using webhook secret, not API secret
Idempotency
Handle duplicate webhook deliveries:
const processedEvents = new Set(); // In production, use Redis/database
async function processWebhook(event) {
const eventId = event.id;
// Check if already processed
if (processedEvents.has(eventId)) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
try {
// Process event
await handleEvent(event);
// Mark as processed
processedEvents.add(eventId);
// In production, persist to database
await db.saveProcessedEvent(eventId, new Date());
} catch (error) {
console.error(`Error processing event ${eventId}:`, error);
// Don't mark as processed - allow retry
throw error;
}
}Event Types
Koywe emits events across several resource families. All events share the same top-level envelope (id, type, version, occurred_at, source, environment, organization_id, merchant_id, data) shown in the next section — but the contents of data vary by event family. Fields like orderId and amountIn in the example below are specific to the order family; they do not apply to merchant.*, policy.*, invitation.*, bank_income.*, or webhook.ping events, which carry their own resource-specific data shapes. The taxonomy is additive — new events may be introduced over time, and any breaking changes are signaled via the envelope’s version field.
Order Lifecycle
| Event Type | When Fired |
|---|---|
order.created | Order is first created |
order.approved | Policy approval cleared the order; it moves into PROCESSING |
order.processing | Payment or transfer is being processed |
order.paid | Payment confirmed (payment provider or on-chain confirmation) |
order.completed | Funds settled — credited for PAYIN/ONRAMP, debited for PAYOUT/OFFRAMP |
order.failed | Order failed at any stage (payment, ledger, transfer) |
order.expired | Order passed its due date without being paid |
order.canceled | Order canceled by the user or the system |
order.refunded | Order was refunded after completion |
order.updated | Generic fallback for order state changes not covered above |
Note on spelling: The canonical spelling is order.canceled (one l). Earlier internal drafts used order.cancelled — if you have handlers matching that spelling, switch them to order.canceled.
Merchant & KYB
| Event Type | When Fired |
|---|---|
merchant.created | A merchant is created under an organization |
merchant.kyb.in_progress | KYB review started for the merchant |
merchant.kyb.approved | KYB passed; merchant can operate in production |
merchant.kyb.rejected | KYB was rejected; remediation required |
Invitations
Fired for both user-level invitations and organization-level invitations (distinguishable via the resourceType in the payload: invitation vs organization_invitation).
| Event Type | When Fired |
|---|---|
invitation.created | An invitation is issued |
invitation.accepted | The recipient accepted the invitation |
invitation.expired | The invitation expired before acceptance |
invitation.failed | Invitation delivery or processing failed |
invitation.assigned | Invitation target was resolved/assigned to a user |
Policy Approvals
Fired when a protected operation goes through the policy-approval flow.
| Event Type | When Fired |
|---|---|
policy.approval.requested | A policy triggered; one or more approvers notified |
policy.approval.received | An individual approver responded (approve/reject) |
policy.approval.approved | Threshold met; the request is approved |
policy.approval.rejected | The request was rejected by policy |
Policy Execution
A separate dynamic event fires when a policy finally executes against its target resource. The event type embeds the resource type:
policy.{resourceType}.executedCurrently emitted resource types:
| Event Type | Resource |
|---|---|
policy.order.executed | Order |
policy.account.executed | Bank account |
policy.deal.executed | Deal |
policy.user_invite.executed | User invitation |
policy.passkey_enrollment.executed | Passkey enrollment |
policy.wallet_access_policy.executed | Wallet access policy |
policy.policy.executed | Policy definition |
policy.policy_rule.executed | Policy rule |
If you want to handle all policy executions generically, match on the policy.*.executed pattern rather than enumerating each resource type — new ones are added as new resource kinds become policy-protected.
Bank Income
| Event Type | When Fired |
|---|---|
bank_income.received | A deposit was credited to a virtual bank account — fired for both real bank transfers and sandbox simulations |
System
| Event Type | When Fired |
|---|---|
webhook.ping | Test event emitted by the webhook ping endpoint; use to verify connectivity and signature verification |
Event Payload Structure
{
"id": "evt_abc123xyz",
"type": "order.completed",
"version": "v1",
"occurred_at": "2025-11-13T15:30:00Z",
"source": "koywe.api",
"environment": "production",
"organization_id": "org_xyz789",
"merchant_id": "mrc_abc123",
"data": {
"orderId": "ord_123456",
"type": "PAYIN",
"status": "COMPLETED",
"amountIn": 50000,
"originCurrencySymbol": "COP",
"destinationCurrencySymbol": "COP",
"externalId": "order-12345",
"contactId": "cnt_customer1",
"dates": {
"confirmationDate": "2025-11-13T15:29:00Z",
"paymentDate": "2025-11-13T15:29:30Z",
"deliveryDate": "2025-11-13T15:30:00Z"
}
}
}Event Handling
Complete Event Handler
async function handleEvent(event) {
const { type, data } = event;
switch (type) {
case 'order.created':
await onOrderCreated(data);
break;
case 'order.approved':
await onOrderApproved(data);
break;
case 'order.processing':
await onOrderProcessing(data);
break;
case 'order.paid':
await onOrderPaid(data);
break;
case 'order.completed':
await onOrderCompleted(data);
break;
case 'order.failed':
await onOrderFailed(data);
break;
case 'order.expired':
await onOrderExpired(data);
break;
case 'order.canceled':
await onOrderCanceled(data);
break;
case 'order.refunded':
await onOrderRefunded(data);
break;
case 'order.updated':
await onOrderUpdated(data);
break;
default:
// Other families (merchant.*, merchant.kyb.*, invitation.*,
// policy.approval.*, policy.*.executed, bank_income.*, webhook.ping)
// are handled in their own routers.
console.warn(`Unhandled event type: ${type}`);
}
}
async function onOrderCompleted(data) {
// CRITICAL: This is where you fulfill orders
if (data.type === 'PAYIN') {
// Customer paid, fulfill order
await fulfillOrder(data.externalId);
await sendConfirmationEmail(data.externalId);
await updateInventory(data.externalId);
} else if (data.type === 'PAYOUT') {
// Payout completed, mark as paid
await markInvoiceAsPaid(data.externalId);
await notifyProvider(data.externalId);
}
}
async function onOrderFailed(data) {
// Handle failure
await markOrderFailed(data.externalId);
await sendFailureNotification(data.externalId);
// Log for investigation
console.error('Order failed:', {
orderId: data.orderId,
externalId: data.externalId,
type: data.type
});
}Response Times
Respond to webhooks quickly (< 5 seconds):
app.post('/webhooks/koywe', express.raw({ type: 'application/json' }), async (req, res) => {
// Convert raw body to string once
const rawBody = req.body.toString();
// 1. Verify signature (fast)
if (!verifySignature(rawBody, req.headers['koywe-signature'])) {
return res.status(401).send('Invalid signature');
}
// Parse JSON from string
const event = JSON.parse(rawBody);
// 2. Check idempotency (fast)
if (await isEventProcessed(event.id)) {
return res.status(200).send('Already processed');
}
// 3. Respond immediately
res.status(200).send('OK');
// 4. Process asynchronously (AFTER responding)
setImmediate(async () => {
try {
await processWebhook(event);
await markEventProcessed(event.id);
} catch (error) {
console.error('Webhook processing error:', error);
await logWebhookError(event, error);
}
});
});Best Practice: Respond immediately (200 OK) and process the webhook asynchronously. This prevents timeouts and ensures reliable delivery.
Retry Policy
Koywe sends each webhook once, then retries only if the failure looks transient. The rules are:
- Request timeout: 30 seconds. Slower responses are treated as a network failure.
- Retryable failures — delivery will be retried with backoff:
5xxresponses from your endpoint429 Too Many Requests- Network errors and timeouts (no HTTP response at all)
- Non-retryable failures — delivery is marked
FAILEDafter a single attempt, with no retry:- Any other
4xxresponse (e.g.400,401,403,404,422)
- Any other
Retryable failures are re-enqueued with backoff. Each attempt increments retryCount and appends to the delivery’s attempts[]; once the maximum retry budget is exhausted, deliveryStatus flips to FAILED — this applies to 429 too, so returning 429 signals throttling but does not exempt the delivery from being marked failed if retries are exhausted. You can inspect delivery state via GET /api/v1/organizations/:organizationId/webhook-events/:eventId/deliveries, and re-send a failed event manually with the replay endpoint.
Retries only apply before you ACK. Koywe’s automatic retry logic (5xx / 429 / network timeout) only triggers when it receives an error response — or no response at all — from your endpoint. Once you return 200 OK, the delivery is considered successful and will not be retried, even if your background processing later fails. If you use the ACK-first pattern shown above, you must durably persist the event (e.g., enqueue to a reliable job queue or commit to a database) before returning 200 OK; otherwise a crash between ACK and processing will lose the event and Koywe will not redeliver it automatically — you’ll need to call the replay endpoint manually.
4xx is terminal. If your endpoint returns 401 because a secret rotation hasn’t landed yet, or 404 because of a path typo, Koywe will not retry — it assumes the request is fundamentally wrong. Fix the endpoint, then use POST /api/v1/organizations/:organizationId/webhook-events/:eventId/replay to resend. Return 5xx if you want Koywe to retry automatically (e.g. during transient downstream outages). Return 429 to signal throttling — retries still apply with backoff, and the delivery can still be marked FAILED once the retry budget is exhausted.
Handling Retries
async function processWebhook(event) {
const eventId = event.id;
// Use database for persistence (not in-memory)
const alreadyProcessed = await db.isEventProcessed(eventId);
if (alreadyProcessed) {
console.log(`Event ${eventId} already processed`);
return; // Return success without re-processing
}
// Process event
await handleEvent(event);
// Mark as processed AFTER successful handling
await db.markEventProcessed(eventId, {
processedAt: new Date(),
eventType: event.type,
orderId: event.data.orderId
});
}Testing Webhooks
Local Testing with ngrok
Install ngrok
npm install -g ngrokStart your local server
node server.js
# Server running on http://localhost:3000Create ngrok tunnel
ngrok http 3000Use ngrok URL
Forwarding: https://abc123.ngrok.io -> http://localhost:3000Configure webhook
Use https://abc123.ngrok.io/webhooks/koywe as your webhook URL
Test
Create orders and watch webhooks arrive in real-time
Using webhook.site
For quick testing without code:
- Visit https://webhook.site
- Copy your unique URL
- Use as webhook URL in API
- View incoming webhooks in browser
Production Checklist
Before going live:
- Signature verification implemented
- Idempotency handling in place
- Quick response times (< 5 seconds)
- Async processing implemented
- Error logging configured
- Event persistence to database
- Monitoring and alerts setup
- Tested with ngrok/webhook.site
- Webhook endpoint is HTTPS
- Firewall allows Koywe IPs
Monitoring and Debugging
Webhook Logs
Query webhook delivery attempts:
async function getWebhookDeliveries(token, orgId, orderId) {
const response = await axios.get(
`https://api.koywe.com/api/v1/organizations/${orgId}/webhook-events`,
{
params: { orderId },
headers: { 'Authorization': `Bearer ${token}` }
}
);
return response.data;
}
// Usage
const deliveries = await getWebhookDeliveries(token, orgId, 'ord_123');
deliveries.forEach(d => {
console.log(`Attempt ${d.attempt}: ${d.status} (${d.responseCode})`);
console.log(` Delivered at: ${d.deliveredAt}`);
console.log(` Response time: ${d.responseTime}ms`);
});Replay Webhooks
Replay a webhook manually:
async function replayWebhook(token, orgId, eventId) {
const response = await axios.post(
`https://api.koywe.com/api/v1/organizations/${orgId}/webhook-events/{eventId}/replay`,
{},
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
console.log('Webhook replayed');
}Security Best Practices
Critical Security Measures:
- Always verify signatures - Never skip this step
- Use HTTPS - Webhooks over HTTP are insecure
- Validate event structure - Check required fields exist
- Use webhook secret - Don’t use API secret
- Log all webhooks - For audit trail
- Rate limit your endpoint - Protect against attacks