Koywe
On this page

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');
  }
);

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:

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

Text
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

JSON
{
  "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

JavaScript
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):

JavaScript
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

JavaScript
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

  1. Install ngrok

    Terminal
    npm install -g ngrok
  2. Start your local server

    Terminal
    node server.js
    # Server running on http://localhost:3000
  3. Create ngrok tunnel

    Terminal
    ngrok http 3000
  4. Use ngrok URL

    Text
    Forwarding: https://abc123.ngrok.io -> http://localhost:3000
  5. Configure webhook

    Use https://abc123.ngrok.io/webhooks/koywe as your webhook URL

  6. Test

    Create orders and watch webhooks arrive in real-time

Using webhook.site

For quick testing without code:

  1. Visit
  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:

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

JavaScript
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