By Bonaventure Ogeto|

Webhooks vs APIs: The Difference With Payment Examples

An API call is you asking a server for information: "Has the payment been completed?" A webhook is the server telling you when something happens: "The payment just completed, here are the details." APIs are pull-based (you initiate), webhooks are push-based (the service initiates). Payment systems like M-Pesa and Paystack use both: you call their API to initiate a payment, and they send a webhook to your server when the payment completes.

APIs pull, webhooks push

Imagine you order food for delivery. There are two ways to know when it arrives:

API approach (polling): You call the restaurant every 30 seconds. "Is my food ready?" "No." "Is it ready now?" "No." "How about now?" "Yes, the rider just left." This works, but it wastes your time and the restaurant's time.

Webhook approach: You give the restaurant your phone number and say "text me when the rider leaves." You go about your day. When the food is ready, the restaurant texts you. No repeated calls, no wasted effort.

In software:

  • API call: Your server sends a request to another service. "Give me the payment status for transaction XYZ." You decide when to ask.
  • Webhook: Another service sends a request to your server. "Transaction XYZ just completed. Here are the details." The service decides when to notify you.

M-Pesa callbacks: a real webhook example

When you integrate M-Pesa STK Push (the popup that asks users to enter their PIN), the flow uses both an API call and a webhook:

Step 1 (API call): You initiate the payment.

// Your server calls the M-Pesa API
const response = await fetch(
  'https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      BusinessShortCode: '174379',
      Amount: '1500',
      PartyA: '254712345678',
      PhoneNumber: '254712345678',
      CallBackURL: 'https://yourapp.com/api/mpesa/callback',
      TransactionDesc: 'Course payment',
    }),
  }
);
// Response: { MerchantRequestID: "29115-34620561-1", ResponseCode: "0" }
// This only means the STK push was sent. It does NOT mean the payment is done.

Step 2 (Webhook): M-Pesa calls your server when the user completes or cancels the payment.

// app/api/mpesa/callback/route.ts
// M-Pesa sends a POST request to this URL
export async function POST(req: Request) {
  const body = await req.json();
  const callback = body.Body.stkCallback;

  if (callback.ResultCode === 0) {
    // Payment successful
    const amount = callback.CallbackMetadata.Item
      .find((i: any) => i.Name === 'Amount')?.Value;
    const mpesaRef = callback.CallbackMetadata.Item
      .find((i: any) => i.Name === 'MpesaReceiptNumber')?.Value;

    // Update your database
    await db.payments.update({
      where: { merchantRequestId: callback.MerchantRequestID },
      data: {
        status: 'completed',
        mpesaRef,
        amount,
      },
    });
  } else {
    // Payment failed or cancelled
    await db.payments.update({
      where: { merchantRequestId: callback.MerchantRequestID },
      data: { status: 'failed' },
    });
  }

  // Always return 200 to acknowledge receipt
  return Response.json({ ResultCode: 0, ResultDesc: 'Accepted' });
}

The CallBackURL you provide in the STK Push request is your webhook endpoint. M-Pesa will POST to that URL when the transaction resolves. If your server does not respond with a 200 status, M-Pesa will retry the callback.

Paystack webhooks: another payment example

Paystack works similarly. After a customer completes a payment on the Paystack checkout page, Paystack sends a webhook to your server:

// app/api/paystack/webhook/route.ts
import crypto from 'crypto';

export async function POST(req: Request) {
  // Verify the webhook signature
  const body = await req.text();
  const signature = req.headers.get('x-paystack-signature');
  const hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY!)
    .update(body)
    .digest('hex');

  if (hash !== signature) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(body);

  if (event.event === 'charge.success') {
    const { reference, amount, customer } = event.data;

    // Update your database
    await db.orders.update({
      where: { paymentRef: reference },
      data: {
        status: 'paid',
        amountPaid: amount / 100, // Paystack amounts are in kobo/cents
        paidAt: new Date(),
      },
    });
  }

  return Response.json({ received: true });
}

Notice the signature verification. This is critical. Without it, anyone could send a fake POST request to your webhook URL and trick your app into thinking a payment was made. Always verify webhook signatures.

Why not just poll the API instead?

You could skip webhooks entirely and check the payment status by calling the API repeatedly:

// Polling approach (wasteful)
async function checkPaymentStatus(transactionId: string) {
  for (let i = 0; i < 60; i++) {
    const res = await fetch(`/api/check-payment/${transactionId}`);
    const data = await res.json();

    if (data.status === 'completed') {
      return data;
    }

    // Wait 5 seconds before checking again
    await new Promise(resolve => setTimeout(resolve, 5000));
  }

  throw new Error('Payment timed out');
}

This works, but it has real problems:

  • Wasted resources: 59 out of 60 checks return "still pending." Each check costs network bandwidth and API rate limit quota.
  • Delayed detection: If you check every 5 seconds, you might detect the payment up to 5 seconds late. Webhooks notify you instantly.
  • Rate limits: Payment APIs limit how many requests you can make per minute. Polling eats into that limit.
  • Scalability: With 100 concurrent payments, polling means 100 loops running simultaneously. Webhooks mean 100 incoming requests when payments complete, which is far more efficient.

That said, smart systems use both. You listen for the webhook as the primary mechanism, and you have a background job that polls for any payments where the webhook might have been missed (network issues, server downtime). This is called a reconciliation process.

Best practices for building webhook endpoints

1. Always verify the signature. Both M-Pesa and Paystack sign their webhook payloads. Verify the signature before processing. Without verification, anyone can forge a webhook request.

2. Respond with 200 immediately. Process the webhook data asynchronously if it takes time (like sending emails or updating multiple tables). The sending service expects a quick response. If your endpoint takes too long, the service might time out and retry, leading to duplicate processing.

export async function POST(req: Request) {
  const body = await req.json();

  // Respond immediately
  // Queue the actual processing for background execution
  await queue.add('process-payment', body);

  return Response.json({ received: true });
}

3. Handle duplicates (idempotency). Webhook providers retry on failure, which means you might receive the same event twice. Use the transaction ID or event ID to check whether you have already processed it.

// Check for duplicate processing
const existing = await db.payments.findUnique({
  where: { mpesaRef: event.mpesaReceiptNumber },
});

if (existing && existing.status === 'completed') {
  // Already processed, skip
  return Response.json({ received: true });
}

4. Log everything. Webhook debugging is hard because you cannot reproduce the incoming request easily. Log the raw payload, your verification result, and the action you took. This makes troubleshooting much easier.

5. Use HTTPS. Your webhook URL must be HTTPS. Payment providers will not send sensitive payment data to an unencrypted endpoint.

Frequently Asked Questions

Can I test webhooks on localhost?
Not directly, because services like M-Pesa and Paystack need a public URL to send webhooks to. Use a tunneling tool like ngrok, which gives your localhost a public URL. Run "ngrok http 3000" and use the generated URL as your CallBackURL. For development, Paystack also has a test mode where you can manually trigger webhook events from their dashboard.
What happens if my webhook endpoint is down when the event fires?
Most providers retry webhook delivery several times over a period of hours. Paystack retries for up to 72 hours. M-Pesa also retries on failure. This is why your endpoint should be idempotent: receiving the same event twice should not create duplicate records or charge a customer twice.
Do I always need webhooks for payments?
For server-side payment confirmation, yes. Client-side redirects (like when Paystack redirects the user back to your site after payment) are not reliable because the user might close the browser. Webhooks ensure your server always gets the payment confirmation, regardless of what the user does in their browser.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.

See Programs