Overview

Webhooks provide real-time notifications about transaction status changes, eliminating the need for polling. When a transaction completes, fails, or changes status, FundKit sends an HTTP POST request to your configured endpoint with the updated information.
Webhooks are optional. You can poll for transaction status using getTransaction(), or configure webhooks for real-time updates. Webhooks are recommended for production but not required for sandbox testing.

Why Use Webhooks?

Real-time Updates

Get notified instantly when transactions complete, fail, or change status

Reduced API Calls

No need to poll for status updates - webhooks push updates to you

Better Performance

Lower latency and reduced server load compared to polling

Reliable Delivery

Built-in retry mechanisms ensure webhook delivery

Webhook Flow

1

Configure Webhook URL

Set your webhook endpoint URL and secret in PaymentClient configuration
2

Transaction Event Occurs

Payment completes, fails, or status changes
3

Webhook Sent

FundKit sends HTTP POST to your endpoint with event data
4

Verify & Process

Your server verifies signature and processes the event
5

Respond with 200

Acknowledge receipt with HTTP 200 status code

Configuration

PaymentClient-Level Configuration

Webhooks are configured at the PaymentClient level, not at the provider level. This provides a unified webhook endpoint for all providers:

import { PaymentClient } from '@fundkit/core';
import { HoneyCoin } from '@fundkit/honeycoin';
import { EasyPay } from '@fundkit/easypay';

const honeycoin = new HoneyCoin({
  apiKey: process.env.HONEYCOIN_API_KEY!,
  publicKey: process.env.HONEYCOIN_PUBLIC_KEY!,
});

const easypay = new EasyPay({
  apiKey: process.env.EASYPAY_SECRET!,
  clientId: process.env.EASYPAY_CLIENT_ID!,
});

const client = new PaymentClient({
apiKey: process.env.FUNDKIT_API_KEY!,
providers: [honeycoin, easypay],
environment: 'sandbox',
webhook: {
url: 'https://api.myapp.com/webhooks/fundkit',
secret: process.env.WEBHOOK_SECRET!,
},
});

All providers will use the same webhook endpoint configured in PaymentClient. The webhook payload will include a provider field to identify which provider processed the transaction.

Production Webhook Setup

When moving to production, you need to configure webhooks in your FundKit dashboard and each provider’s dashboard. This setup is only required for production - sandbox mode works without webhook configuration.

Video Tutorial

Watch this step-by-step guide on how to activate and use webhooks in the FundKit dashboard:
1

Navigate to Provider Webhooks

Go to your app dashboard → Providers tab → Provider Webhooks section
Provider Webhooks dashboard showing webhook URLs for each provider
2

Activate Webhooks

Toggle on the webhook for each provider you’re using (HoneyCoin, EasyPay, Tola, Tingg, etc.) The webhook will show as Active (purple toggle) when enabled.
3

Copy Webhook URLs

Click the Copy button next to each provider’s webhook URL to copy it to your clipboard
4

Configure in Provider Dashboard

Paste the webhook URL into the corresponding provider’s dashboard: - HoneyCoin: Dashboard → Settings → Webhooks - EasyPay: Dashboard → Integration → Webhooks - Tola: Dashboard → Configuration → Webhooks - Tingg: Dashboard → Settings → Webhook Configuration - And so on for other providers
Note: - Webhook configuration is only required for production. In sandbox mode, you can test transactions without webhooks by polling for status using getTransaction(). - The webhook URL in your FundKit dashboard is automatically generated and unique to your app. - You must activate and configure webhooks for each provider you plan to use in production.

Webhook Events

Common Event Types

Triggered when: Payment is successfully completedTypical timing: 30 seconds to 5 minutes after initiationAction required: Update order status, fulfill service, send confirmation
{
  "event": "transaction_completed",
  "data": {
    "transactionId": "tx_1763540996633_x8jbw9qb41s",
    "transactionType": "COLLECTION",
    "amount": "9500",
    "currency": "UGX",
    "operator": "mtn",
    "accountNumber": "256779280949",
    "externalId": "x8jbw9qb41s",
    "environment": "sandbox"
  }
}
Triggered when: Payment fails for any reasonTypical timing: Immediately to 5 minutes after initiationAction required: Update order status, notify customer, log for analysis
{
  "event": "transaction_failed",
  "data": {
    "transactionId": "tx_1763540996633_x8jbw9qb41s",
    "transactionType": "COLLECTION",
    "amount": "9500",
    "currency": "UGX",
    "operator": "mtn",
    "accountNumber": "256779280949",
    "externalId": "x8jbw9qb41s",
    "environment": "sandbox"
  }
}
Triggered when: Payment is initiated and waiting for customer actionTypical timing: Immediately after payment initiationAction required: Show pending status to customer
{
  "event": "transaction_pending",
  "data": {
    "transactionId": "tx_1763540996633_x8jbw9qb41s",
    "transactionType": "COLLECTION",
    "amount": "9500",
    "currency": "UGX",
    "operator": "mtn",
    "accountNumber": "256779280949",
    "externalId": "x8jbw9qb41s",
    "environment": "sandbox"
  }
}

Webhook Event Structure

All webhook events follow this structure:
{
"event": "transaction_completed" | "transaction_failed" | "transaction_pending",
  "data": {
"transactionId": "string",
"transactionType": "COLLECTION" | "PAYOUT",
"amount": "string",
"currency": "string",
"operator": "string",
"accountNumber": "string",
"externalId": "string",
"environment": "sandbox" | "production"
  }
}
Event Types:
  • transaction_completed - Payment successfully completed
  • transaction_failed - Payment failed
  • transaction_pending - Payment initiated and waiting for customer action
Data Fields:
  • transactionId - Unique transaction identifier
  • transactionType - Type of transaction (COLLECTION or PAYOUT)
  • amount - Transaction amount as a string (in smallest currency unit)
  • currency - Currency code (e.g., UGX, KES, TZS)
  • operator - Mobile money operator (e.g., mtn, airtel, mpesa)
  • accountNumber - Customer account/phone number
  • externalId - Your external reference ID
  • environment - Environment where transaction occurred (sandbox or production)

Testing Webhooks

Local Development with ngrok

Set up local webhook testing:
1

Install ngrok

npm install -g ngrok
# or download from https://ngrok.com/
2

Start Your Local Server

npm run dev  # Your app running on localhost:3000
3

Expose with ngrok

ngrok http 3000
Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
4

Configure Webhook URL

const client = new PaymentClient({
  apiKey: process.env.FUNDKIT_API_KEY!,
  providers: [honeycoin, easypay],
  environment: 'sandbox',
  webhook: {
    url: 'https://abc123.ngrok.io/webhooks/fundkit',
    secret: process.env.WEBHOOK_SECRET!,
  },
});

Webhook Testing Utility

class WebhookTester {
  private webhookUrl: string;
  private secret: string;

  constructor(webhookUrl: string, secret: string) {
    this.webhookUrl = webhookUrl;
    this.secret = secret;
  }

  async sendTestWebhook(eventType: string, data: any) {
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const payload = JSON.stringify({
      event: eventType,
      data: data,
    });

    const signature = this.generateSignature(payload, timestamp);

    const response = await fetch(this.webhookUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': `sha256=${signature}`,
        'X-Webhook-Timestamp': timestamp,
      },
      body: payload,
    });

    console.log('Test webhook response:', response.status);
    return response;
  }

  private generateSignature(payload: string, timestamp: string): string {
    return crypto.createHmac('sha256', this.secret).update(`${timestamp}.${payload}`).digest('hex');
  }

  async testTransactionCompleted(externalId: string) {
    return this.sendTestWebhook('transaction_completed', {
      transactionId: `tx_${Date.now()}_${externalId}`,
      transactionType: 'COLLECTION',
      amount: '10000',
      currency: 'UGX',
      operator: 'mtn',
      accountNumber: '256701234567',
      externalId: externalId,
      environment: 'sandbox',
    });
  }

  async testTransactionFailed(externalId: string) {
    return this.sendTestWebhook('transaction_failed', {
      transactionId: `tx_${Date.now()}_${externalId}`,
      transactionType: 'COLLECTION',
      amount: '10000',
      currency: 'UGX',
      operator: 'mtn',
      accountNumber: '256701234567',
      externalId: externalId,
      environment: 'sandbox',
    });
  }

  async testTransactionPending(externalId: string) {
    return this.sendTestWebhook('transaction_pending', {
      transactionId: `tx_${Date.now()}_${externalId}`,
      transactionType: 'COLLECTION',
      amount: '10000',
      currency: 'UGX',
      operator: 'mtn',
      accountNumber: '256701234567',
      externalId: externalId,
      environment: 'sandbox',
    });
  }
}

// Usage
const tester = new WebhookTester('http://localhost:3000/webhooks/test', 'test_webhook_secret');

await tester.testTransactionCompleted('test_order_123');
await tester.testTransactionFailed('test_order_456');
await tester.testTransactionPending('test_order_789');

Troubleshooting

Possible causes:
  • Webhook URL not configured correctly
  • Server not accessible from internet
  • Firewall blocking webhook provider IPs
Solutions:
  • Verify webhook URL in provider dashboard
  • Test endpoint with curl or webhook testing tools
  • Check firewall and security group settings
  • Use ngrok for local development testing
Possible causes:
  • Wrong webhook secret
  • Body parsing modifying payload
  • Incorrect signature calculation
Solutions:
  • Verify webhook secret in environment variables
  • Use raw body parser for webhook endpoints
  • Log received vs expected signatures for debugging
  • Check timestamp validation logic
Possible causes:
  • Webhook endpoint returning non-200 status
  • Processing taking too long
  • Network timeouts
Solutions:
  • Always return 200 for successfully received webhooks
  • Implement idempotency using event IDs
  • Process webhooks asynchronously
  • Respond quickly (< 5 seconds)
Possible causes:
  • Provider service issues
  • Webhook URL changed
  • Rate limiting
Solutions:
  • Check provider status pages
  • Verify webhook configuration
  • Implement webhook retry/recovery mechanisms
  • Use transaction status polling as backup

Next Steps