Skip to Content
Advanced TopicsWebhooks Deep Dive

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', 200

Common Mistakes:

  • Using express.json() instead of express.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 TypeWhen Fired
order.createdOrder is first created
order.approvedPolicy approval cleared the order; it moves into PROCESSING
order.processingPayment or transfer is being processed
order.paidPayment confirmed (payment provider or on-chain confirmation)
order.completedFunds settled — credited for PAYIN/ONRAMP, debited for PAYOUT/OFFRAMP
order.failedOrder failed at any stage (payment, ledger, transfer)
order.expiredOrder passed its due date without being paid
order.canceledOrder canceled by the user or the system
order.refundedOrder was refunded after completion
order.updatedGeneric 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 TypeWhen Fired
merchant.createdA merchant is created under an organization
merchant.kyb.in_progressKYB review started for the merchant
merchant.kyb.approvedKYB passed; merchant can operate in production
merchant.kyb.rejectedKYB 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 TypeWhen Fired
invitation.createdAn invitation is issued
invitation.acceptedThe recipient accepted the invitation
invitation.expiredThe invitation expired before acceptance
invitation.failedInvitation delivery or processing failed
invitation.assignedInvitation target was resolved/assigned to a user

Policy Approvals

Fired when a protected operation goes through the policy-approval flow.

Event TypeWhen Fired
policy.approval.requestedA policy triggered; one or more approvers notified
policy.approval.receivedAn individual approver responded (approve/reject)
policy.approval.approvedThreshold met; the request is approved
policy.approval.rejectedThe 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}.executed

Currently emitted resource types:

Event TypeResource
policy.order.executedOrder
policy.account.executedBank account
policy.deal.executedDeal
policy.user_invite.executedUser invitation
policy.passkey_enrollment.executedPasskey enrollment
policy.wallet_access_policy.executedWallet access policy
policy.policy.executedPolicy definition
policy.policy_rule.executedPolicy 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 TypeWhen Fired
bank_income.receivedA deposit was credited to a virtual bank account — fired for both real bank transfers and sandbox simulations

System

Event TypeWhen Fired
webhook.pingTest 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:
    • 5xx responses from your endpoint
    • 429 Too Many Requests
    • Network errors and timeouts (no HTTP response at all)
  • Non-retryable failures — delivery is marked FAILED after a single attempt, with no retry:
    • Any other 4xx response (e.g. 400, 401, 403, 404, 422)

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 ngrok

Start your local server

node server.js # Server running on http://localhost:3000

Create ngrok tunnel

ngrok http 3000

Use ngrok URL

Forwarding: https://abc123.ngrok.io -> http://localhost:3000

Configure 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:

  1. Visit https://webhook.site 
  2. Copy your unique URL
  3. Use as webhook URL in API
  4. 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:

  1. Always verify signatures - Never skip this step
  2. Use HTTPS - Webhooks over HTTP are insecure
  3. Validate event structure - Check required fields exist
  4. Use webhook secret - Don’t use API secret
  5. Log all webhooks - For audit trail
  6. Rate limit your endpoint - Protect against attacks

Next Steps

Last updated on