Webhooks
Webhooks send an HTTPS POST to your server whenever something changes, so you don't have to poll.
Set up
In the dashboard, go to Settings → Webhooks → New and fill in:
- Endpoint: an
https://URL on your server. - Challenge: a secret string that you choose. It is sent back in every webhook so you can check that the request came from IntaSend.
- Events: the events to subscribe to.
Events
topic | Fires when | Payload |
|---|---|---|
collection_event | A payment (invoice) is created or changes state | Invoice |
send_money_event | A payout batch is created or changes state | Payout batch with its transactions |
reversal_event | A chargeback or refund is created or changes status | Chargeback |
wallet_transfer_event | Funds move between your wallets (a transfer or a split) | Both wallets and transactions |
subscription_event | A subscription's status, completed cycles or failure reason changes | Subscription with its payments |
Payload format
Every webhook is a POST with Content-Type: application/json. The body is the resource itself
(the same fields the API returns), with two fields added at the top level:
| Field | Description |
|---|---|
topic | The event type, for example collection_event |
challenge | The challenge you set on the webhook. Compare it with your own before trusting the request |
{
"invoice_id": "KQ9PLXR",
"state": "COMPLETE",
"...": "...other resource fields...",
"topic": "collection_event",
"challenge": "my-secret-challenge"
}
- Amounts are strings in webhooks, for example
"value": "100.00", even where the REST API returns a number. Parse them as decimals, not floats. - Timestamps are ISO 8601 with the East Africa Time offset, for example
"2026-09-30T10:15:42.519204+03:00". Dates without a time areYYYY-MM-DD. - Fields with no value are sent as
null. They are never left out.
A resource can send several events as it moves through its states (for example PENDING →
PROCESSING → COMPLETE). Events may arrive out of order, so use the state or status in the
payload rather than the order they arrive in.
collection_event
This event is sent when a payment (invoice) is created and every time its state changes. It
covers every collection method: M-Pesa, mobile money, checkout, cards, payment links and bank.
Invoices with a value of zero don't send events.
- Completed
- Failed
- Card
{
"invoice_id": "KQ9PLXR",
"state": "COMPLETE",
"provider": "M-PESA",
"charges": "3.00",
"net_amount": "97.00",
"currency": "KES",
"value": "100.00",
"account": "254712345678",
"api_ref": "order-123",
"provider_ref": "SJT8H2K9QW",
"clearing_status": "CLEARING",
"mpesa_reference": "SJT8H2K9QW",
"host": "102.215.33.10",
"card_info": {"bin_country": null, "card_type": null},
"splits": [],
"retry_count": 0,
"failed_reason": null,
"failed_code": null,
"failed_code_link": null,
"created_at": "2026-09-30T10:15:02.114203+03:00",
"updated_at": "2026-09-30T10:15:42.519204+03:00",
"topic": "collection_event",
"challenge": "my-secret-challenge"
}
{
"invoice_id": "KQ9PLXR",
"state": "FAILED",
"provider": "M-PESA",
"charges": "0.00",
"net_amount": "100.00",
"currency": "KES",
"value": "100.00",
"account": "254712345678",
"api_ref": "order-123",
"provider_ref": null,
"clearing_status": null,
"mpesa_reference": null,
"host": "102.215.33.10",
"card_info": {"bin_country": null, "card_type": null},
"splits": [],
"retry_count": 0,
"failed_reason": "Request cancelled by user",
"failed_code": "1032",
"failed_code_link": null,
"created_at": "2026-09-30T10:15:02.114203+03:00",
"updated_at": "2026-09-30T10:15:31.008812+03:00",
"topic": "collection_event",
"challenge": "my-secret-challenge"
}
{
"invoice_id": "RZ4MNQ2",
"state": "COMPLETE",
"provider": "CARD-PAYMENT",
"charges": "0.70",
"net_amount": "19.30",
"currency": "USD",
"value": "20.00",
"api_ref": "order-124",
"provider_ref": "7288390911236789104953",
"clearing_status": "CLEARING",
"mpesa_reference": null,
"host": "https://shop.example.com",
"card_info": {"bin_country": "US", "card_type": "VISA"},
"splits": [],
"retry_count": 0,
"failed_reason": null,
"failed_code": null,
"failed_code_link": null,
"created_at": "2026-09-30T11:02:10.551100+03:00",
"updated_at": "2026-09-30T11:02:18.902431+03:00",
"topic": "collection_event",
"challenge": "my-secret-challenge"
}
| Field | Type | Description |
|---|---|---|
invoice_id | string | Invoice ID. Use it to check payment status |
state | string | PENDING, PROCESSING, RETRY, PARTIAL, COMPLETE, FAILED or CANCELED (meanings) |
provider | string | Payment method, for example M-PESA, INTASEND-XB-PUSH, CARD-PAYMENT, APPLE-PAY, GOOGLE-PAY, PESALINK, BITCOIN, COOP_B2B |
value | string | Amount the customer was asked to pay |
charges | string | IntaSend fees |
net_amount | string | value minus charges |
currency | string | Invoice currency |
account | string | The customer's phone number (mobile methods) or email (card and other methods) |
api_ref | string or null | The reference you sent when creating the payment |
provider_ref | string or null | The provider's reference, for example the M-Pesa receipt number |
mpesa_reference | string or null | M-Pesa receipt number, for M-Pesa payments |
clearing_status | string or null | Status of the funds in your wallet once paid: CLEARING, AVAILABLE or ON-HOLD |
host | string or null | Where the payment was started: your site URL or the client IP |
card_info | object | bin_country and card_type for card payments, otherwise both null |
splits | array | Split results, once the payment has cleared. Otherwise [] |
retry_count | integer | Number of retries |
failed_reason | string or null | Why the payment failed or was cancelled |
failed_code | string or null | Provider failure code, for example an M-Pesa result code like 1032 (cancelled) or 1037 (phone unreachable) |
failed_code_link | null | Reserved |
created_at, updated_at | string | Timestamps |
Only release goods when state is COMPLETE. Match the payment to your order with api_ref, and
check that value and currency are what you expected.
send_money_event
This event is sent when a payout batch is created and every time its state changes: waiting for approval, balance check, sending, completed, and so on. The payload is the full batch, with every transaction in it. Batches with a total of zero don't send events.
{
"file_id": "Y8Q2KLM",
"tracking_id": "8b0a3d0c-6f4e-4a8e-9a53-2f3c1b1f6e0d",
"batch_reference": "payroll-2026-09",
"status": "Completed",
"status_code": "BC100",
"transactions": [
{
"transaction_id": "KQ9PLXR",
"status": "Successful",
"status_code": "TS100",
"status_description": "The service request is processed successfully.",
"request_reference_id": "3f6c2a8e-1b7d-4f0a-9c2e-5d8b7a6f4e21",
"provider": "MPESA-B2C",
"bank_code": null,
"name": "Jane Doe",
"account": "254712345678",
"account_type": null,
"account_reference": null,
"notify_phone": null,
"provider_reference": "SJT8H2K9QW",
"provider_account_name": "JANE DOE",
"amount": "150.00",
"charge": "10.00",
"narrative": "Salary",
"file_id": "Y8Q2KLM",
"idempotency_key": "payout-4411",
"currency": "KES",
"created_at": "2026-09-30T09:00:01.100200+03:00",
"updated_at": "2026-09-30T09:00:19.402113+03:00"
}
],
"actual_charges": "10.00",
"paid_amount": "150.00",
"failed_amount": "0.00",
"wallet": {
"wallet_id": "XZ3LKQ",
"label": "default",
"can_disburse": true,
"currency": "KES",
"wallet_type": "SETTLEMENT",
"current_balance": "12340.00",
"available_balance": "12340.00",
"updated_at": "2026-09-30T09:00:19.402113+03:00"
},
"charge_estimate": "10.00",
"total_amount_estimate": "160.00",
"total_amount": "150.00",
"transactions_count": 1,
"created_at": "2026-09-30T09:00:01.100200+03:00",
"updated_at": "2026-09-30T09:00:19.402113+03:00",
"topic": "send_money_event",
"challenge": "my-secret-challenge"
}
Batch fields:
| Field | Type | Description |
|---|---|---|
file_id | string | Batch ID |
tracking_id | string (UUID) | The tracking ID returned by initiate. Use it with status |
batch_reference | string or null | Your own reference for the batch |
status, status_code | string | Batch state, for example Completed / BC100 (all codes) |
transactions | array | Every transaction in the batch (below) |
total_amount | string | Sum of the transaction amounts |
charge_estimate, total_amount_estimate | string | Estimated fees, and the amount plus fees |
actual_charges | string | Fees actually charged |
paid_amount | string | Amount paid out successfully |
failed_amount | string | Amount that failed and is refunded to the wallet |
transactions_count | integer | Number of transactions |
wallet | object | The wallet the batch was paid from, with balances at the time of the event |
created_at, updated_at | string | Timestamps |
Transaction fields:
| Field | Type | Description |
|---|---|---|
transaction_id | string | Transaction ID. Use it for OTP confirmation |
status, status_code | string | Transaction state, for example Successful / TS100 (all codes) |
status_description | string | The provider's result message, or a default description |
request_reference_id | string (UUID) | Unique reference for this transaction |
provider | string | Payout provider, for example MPESA-B2C or PESALINK |
account, name, narrative | string | As sent in the request |
bank_code, account_type, account_reference, notify_phone | string or null | As sent in the request |
provider_reference | string or null | The provider's reference, for example the M-Pesa receipt |
provider_account_name | string or null | Recipient name reported by the provider |
amount, charge | string | Amount sent and the fee for it |
currency | string | Wallet currency |
file_id | string | The batch this transaction belongs to |
idempotency_key | string or null | Your idempotency key |
created_at, updated_at | string | Timestamps |
A completed batch (BC100) can contain failed transactions. Check each transaction's
status_code before marking a payout as paid.
reversal_event
This event is sent when a chargeback or refund is created and every time its
status changes.
{
"chargeback_id": "CB7X2QL",
"session_id": "5b9d6c1e-8a3f-4e2b-9d7c-1f0e2a3b4c5d",
"transaction": {
"transaction_id": "TR8LQ3M",
"invoice": {
"invoice_id": "KQ9PLXR",
"state": "COMPLETE",
"provider": "M-PESA",
"value": "100.00",
"currency": "KES",
"account": "254712345678",
"api_ref": "order-123",
"...": "...all invoice fields, as in collection_event..."
},
"currency": "KES",
"value": "97.00",
"running_balance": "12437.00",
"narrative": "Payment from 254712345678",
"trans_type": "SALE",
"status": "CHARGEBACK-PENDING",
"created_at": "2026-09-30T10:15:42.519204+03:00",
"updated_at": "2026-09-30T12:01:05.771020+03:00"
},
"amount": "100.00",
"reason": "Duplicate payment",
"status": "PENDING",
"resolution": null,
"staff_created": false,
"created_at": "2026-09-30T12:01:05.771020+03:00",
"updated_at": "2026-09-30T12:01:05.771020+03:00",
"topic": "reversal_event",
"challenge": "my-secret-challenge"
}
| Field | Type | Description |
|---|---|---|
chargeback_id | string | Chargeback ID |
session_id | string or null | Payment session of the original payment |
transaction | object | The original payment's wallet transaction, with the full invoice nested inside. Its status shows the effect on your funds, for example CHARGEBACK-PENDING or REFUNDED |
amount | string | Amount being refunded |
reason | string | Unavailable service, Delayed delivery, Wrong service, Duplicate payment or Other |
status | string | PENDING, PROCESSING, COMPLETED, DISPUTED, OVERDUE or CANCELLED |
resolution | string or null | How it was resolved |
staff_created | boolean | true if it was raised by IntaSend staff rather than by you |
created_at, updated_at | string | Timestamps |
wallet_transfer_event
This event is sent when funds move between your own wallets:
intra-wallet transfers and each executed
split. Unlike the other events, the payload has a
from_data / to_data structure:
{
"from_data": {
"wallet": {
"wallet_id": "XZ3LKQ",
"label": "default",
"can_disburse": true,
"currency": "KES",
"wallet_type": "SETTLEMENT",
"current_balance": "11840.00",
"available_balance": "11840.00",
"updated_at": "2026-09-30T13:20:11.004512+03:00"
},
"transaction": {
"transaction_id": "TR2PX9A",
"invoice": null,
"currency": "KES",
"value": "-500.00",
"running_balance": "11840.00",
"narrative": "Float top-up",
"trans_type": "PAYOUT",
"status": "AVAILABLE",
"created_at": "2026-09-30T13:20:11.004512+03:00",
"updated_at": "2026-09-30T13:20:11.004512+03:00"
}
},
"to_data": {
"wallet": {
"wallet_id": "WK7NB3R",
"label": "Nairobi branch",
"can_disburse": true,
"currency": "KES",
"wallet_type": "WORKING",
"current_balance": "2500.00",
"available_balance": "2500.00",
"updated_at": "2026-09-30T13:20:11.004512+03:00"
},
"transaction": {
"transaction_id": "TR2PX9B",
"invoice": null,
"currency": "KES",
"value": "500.00",
"running_balance": "2500.00",
"narrative": "Float top-up",
"trans_type": "PAYOUT",
"status": "AVAILABLE",
"created_at": "2026-09-30T13:20:11.004512+03:00",
"updated_at": "2026-09-30T13:20:11.004512+03:00"
}
},
"amount": "500.00",
"topic": "wallet_transfer_event",
"challenge": "my-secret-challenge"
}
| Field | Type | Description |
|---|---|---|
from_data.wallet | object | The wallet the money left, with balances after the transfer |
from_data.transaction | object | The debit. Its value is negative |
to_data.wallet | object | The wallet that received the money |
to_data.transaction | object | The credit. Its value is positive |
amount | string | Amount moved |
trans_type tells the two cases apart:
PAYOUT: an intra-wallet transfer that you made.invoiceisnull.SPLIT: an automatic split. The credit'sinvoiceis the payment that was split.
subscription_event
This event is sent when a subscription is created, and whenever its
status, completed_cycles or fail_reason changes. That means activation, each successful
billing cycle, failures and cancellation.
{
"subscription_id": "SB4KQ9M",
"reference": "sub-42",
"customer": {
"customer_id": "CU8XL2P",
"first_name": "Jane",
"last_name": "Doe",
"reference": "user-42",
"address": null,
"city": null,
"state": null,
"zipcode": null,
"country": "KE",
"created_at": "2026-09-01T08:00:00.000000+03:00",
"updated_at": "2026-09-01T08:00:00.000000+03:00"
},
"plan": {
"name": "Pro monthly",
"plan_id": "PL3MNQ7",
"frequency": 1,
"frequency_unit": "M",
"billing_cycles": 12,
"currency": "KES",
"amount": "1000.00",
"reference": null,
"plan_url": "https://payment.intasend.com/subscriptions/charge/PL3MNQ7/plan/",
"redirect_url": null,
"created_at": "2026-09-01T08:00:00.000000+03:00",
"updated_at": "2026-09-01T08:00:00.000000+03:00"
},
"payment_method": "CARD-PAYMENT",
"card_mask": "****1111",
"card_type": "VISA",
"card_expiry": "2026-09-01",
"status": "ACTIVE",
"start_date": "2026-09-01",
"next_date": "2026-11-01",
"completed_cycles": 2,
"fail_reason": null,
"setup_url": "https://payment.intasend.com/subscriptions/charge/…",
"redirect_url": "https://example.com/done",
"payments": [
{
"transaction_id": "SP9QX1A",
"invoice": {"invoice_id": "RZ4MNQ2", "state": "COMPLETE", "value": "1000.00", "currency": "KES", "...": "...all invoice fields..."},
"created_at": "2026-10-01T08:00:04.221900+03:00",
"updated_at": "2026-10-01T08:00:09.874100+03:00"
}
],
"created_at": "2026-09-01T08:00:00.000000+03:00",
"updated_at": "2026-10-01T08:00:09.874100+03:00",
"topic": "subscription_event",
"challenge": "my-secret-challenge"
}
| Field | Type | Description |
|---|---|---|
subscription_id | string | Subscription ID |
reference | string or null | Your reference |
customer | object | The customer (fields) |
plan | object | The plan (fields) |
status | string | PENDING, ACTIVE, CANCELED, COMPLETE or FAILED |
payment_method | string or null | CARD-PAYMENT |
card_mask, card_type | string or null | The saved card (last 4 digits and brand), once the customer has added it |
card_expiry | string or null | A date (YYYY-MM-DD) recorded when the card was saved |
start_date, next_date | string or null | Start date and next billing date (YYYY-MM-DD) |
completed_cycles | integer | Billing cycles paid so far |
fail_reason | string or null | Why the last charge failed |
setup_url | string | Where the customer adds their card |
payments | array | Every charge so far, each with its full invoice |
created_at, updated_at | string | Timestamps |
Payout callback_url
The callback_url you can pass on send money is a separate,
simpler mechanism. It is not a dashboard webhook:
Dashboard webhook (send_money_event) | Per-request callback_url | |
|---|---|---|
| Body | Batch payload, plus topic and challenge | The same batch payload, without topic or challenge |
| When | Every batch state change | Only when the batch completes, fails on low balance, or is cancelled |
| Recorded and replayable | Yes | No |
Because a callback_url request carries no challenge, confirm the result with
/send-money/status/ before acting on it.
Handling webhooks
- Verify the challenge. Reject any request whose
challengedoesn't match yours. - Respond with
200or201quickly, and do heavy work in the background. Any other status counts as a failure. - Be idempotent. You'll get one event per state change, and a replay sends the same event again. Key your handling on the resource ID (
invoice_id,tracking_id,chargeback_id,subscription_id) together with its state. - Confirm critical events. Before releasing goods or marking a payout as paid, look the resource up with the API, for example payment status.
- Python (Flask)
- Node.js (Express)
- PHP
import hmac, os
from flask import Flask, request
app = Flask(__name__)
CHALLENGE = os.environ["INTASEND_WEBHOOK_CHALLENGE"]
@app.post("/intasend/webhook")
def intasend_webhook():
event = request.get_json(force=True)
if not hmac.compare_digest(str(event.get("challenge", "")), CHALLENGE):
return "", 401
topic = event.get("topic")
if topic == "collection_event" and event["state"] == "COMPLETE":
fulfil_order(event["api_ref"], event["invoice_id"])
elif topic == "send_money_event":
for txn in event["transactions"]:
update_payout(txn["idempotency_key"], txn["status_code"])
elif topic == "reversal_event":
record_refund(event["chargeback_id"], event["status"])
elif topic == "subscription_event":
sync_subscription(event["subscription_id"], event["status"])
return "", 200
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use(express.json());
const CHALLENGE = process.env.INTASEND_WEBHOOK_CHALLENGE;
const matches = (a = '', b = '') =>
a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
app.post('/intasend/webhook', (req, res) => {
const event = req.body;
if (!matches(String(event.challenge ?? ''), CHALLENGE)) return res.sendStatus(401);
switch (event.topic) {
case 'collection_event':
if (event.state === 'COMPLETE') fulfilOrder(event.api_ref, event.invoice_id);
break;
case 'send_money_event':
event.transactions.forEach((t) => updatePayout(t.idempotency_key, t.status_code));
break;
case 'reversal_event':
recordRefund(event.chargeback_id, event.status);
break;
case 'subscription_event':
syncSubscription(event.subscription_id, event.status);
break;
}
res.sendStatus(200);
});
<?php
$event = json_decode(file_get_contents('php://input'), true);
$challenge = getenv('INTASEND_WEBHOOK_CHALLENGE');
if (!hash_equals($challenge, (string) ($event['challenge'] ?? ''))) {
http_response_code(401);
exit;
}
switch ($event['topic']) {
case 'collection_event':
if ($event['state'] === 'COMPLETE') {
fulfil_order($event['api_ref'], $event['invoice_id']);
}
break;
case 'send_money_event':
foreach ($event['transactions'] as $txn) {
update_payout($txn['idempotency_key'], $txn['status_code']);
}
break;
}
http_response_code(200);
Failures and replay
Failed deliveries are recorded under Webhooks → Events in the dashboard, and you can replay them from there. A replay sends the same payload again.
If your endpoint keeps failing, IntaSend emails you a warning and then disables the endpoint. Re-enable it in the dashboard once it's fixed.