Invoke when a Nyumbani app receives Safaricom callbacks: C2B validation and confirmation, STK push results, Pull notifications, protecting those routes, the Safaricom IP allow-list, CSRF exemption, or what to answer Safaricom. Skip for making outgoing Daraja calls.
php artisan boost:add-skill nyumbani/daraja
Save this content to: .claude/skills/nyumbani-daraja-webhooks/SKILL.md
# Nyumbani Daraja Webhooks
Safaricom posts to the app's routes, so the app owns the routes, the parsing and the business action. The package only guards them.
## Route
Put the hooks outside any auth group, behind `EnsureRequestIsFromSafaricom`, and exempt from CSRF:
```php
use App\Http\Controllers\DarajaHookController;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Support\Facades\Route;
use Nyumbani\Daraja\Http\Middleware\EnsureRequestIsFromSafaricom;
Route::prefix('hooks/daraja')
->name('hooks.daraja.')
->middleware(EnsureRequestIsFromSafaricom::class)
->withoutMiddleware(PreventRequestForgery::class)
->group(function () {
Route::post('validation', [DarajaHookController::class, 'validation']);
Route::post('confirmation', [DarajaHookController::class, 'confirmation']);
Route::post('stk', [DarajaHookController::class, 'stk']);
Route::post('pull', [DarajaHookController::class, 'pull']);
});
```
With several paybills, include the paybill in the URL, for example `hooks/{paybill}/confirm`, so the controller knows which short code a request belongs to.
## Guard
`EnsureRequestIsFromSafaricom` allows the addresses in `daraja.allowed_ips`, skips the check in `daraja.skip_ip_check_in` (`local`, `testing`), and answers any other address with `ResultCode` `C2B00016` and a warning log. Publish the config only to change those lists; never remove the middleware to get a callback through.
## Answering Safaricom
Respond quickly with HTTP 200 JSON:
```php
return response()->json(['ResultCode' => '0', 'ResultDesc' => 'Accepted']);
```
Keep the raw payload first (type, payload, IP), then do the slow work in a queued job. Do not make Safaricom wait on business logic.
## How the middleware decides
It skips the check in `daraja.skip_ip_check_in` environments, lets through an exact match in `daraja.allowed_ips`, and otherwise logs `Rejected a Daraja request` and answers HTTP 200 with `C2B00016`. It checks the sender's address only: Daraja callbacks carry no signature, so still validate the payload. Behind a proxy or load balancer, configure Laravel's trusted proxies, or every request looks like it comes from the proxy and is rejected.
## Payload shapes
- STK: `Body.stkCallback` with `CheckoutRequestID`, `ResultCode` (`0` paid, `1032` cancelled, `1037` timeout, `2001` wrong PIN) and, on success, `CallbackMetadata.Item` as `Name`/`Value` pairs (`Amount`, `MpesaReceiptNumber`, `PhoneNumber`). Read it with `collect($items)->pluck('Value', 'Name')`.
- C2B: flat fields `TransID`, `TransAmount`, `BillRefNumber`, `BusinessShortCode`, `MSISDN` (which may arrive masked). Refuse a validation with `C2B00011` to `C2B00016`.
## Handling
- Callbacks can arrive more than once. Make the handler idempotent by keying on the transaction id (`TransID`, or the STK `CheckoutRequestID`).
- Treat the payload as untrusted input: validate and cast amounts and phone numbers before using them, and normalise numbers with `daraja_phone()`.
- Validation hooks decide whether a payment is accepted. Reject with a non-zero `ResultCode` only when the account reference is unknown.
## Local development
Safaricom cannot reach a local machine. Expose the app through a tunnel, register the tunnel URLs with `registerC2BUrls()` or `registerPullUrl()` in the sandbox, and test handlers with `$this->postJson('/hooks/daraja/confirmation', $payload)`, which passes the guard in `testing`.
## Testing
Test the guard in a non-skipped environment by setting `daraja.skip_ip_check_in` to `[]`: an unknown IP gets `C2B00016`, an allowed IP reaches the controller.
Safaricom posts to the app's routes, so the app owns the routes, the parsing and the business action. The package only guards them.
Put the hooks outside any auth group, behind EnsureRequestIsFromSafaricom, and exempt from CSRF:
use App\Http\Controllers\DarajaHookController;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Support\Facades\Route;
use Nyumbani\Daraja\Http\Middleware\EnsureRequestIsFromSafaricom;
Route::prefix('hooks/daraja')
->name('hooks.daraja.')
->middleware(EnsureRequestIsFromSafaricom::class)
->withoutMiddleware(PreventRequestForgery::class)
->group(function () {
Route::post('validation', [DarajaHookController::class, 'validation']);
Route::post('confirmation', [DarajaHookController::class, 'confirmation']);
Route::post('stk', [DarajaHookController::class, 'stk']);
Route::post('pull', [DarajaHookController::class, 'pull']);
});
With several paybills, include the paybill in the URL, for example hooks/{paybill}/confirm, so the controller knows which short code a request belongs to.
EnsureRequestIsFromSafaricom allows the addresses in daraja.allowed_ips, skips the check in daraja.skip_ip_check_in (local, testing), and answers any other address with ResultCode C2B00016 and a warning log. Publish the config only to change those lists; never remove the middleware to get a callback through.
Respond quickly with HTTP 200 JSON:
return response()->json(['ResultCode' => '0', 'ResultDesc' => 'Accepted']);
Keep the raw payload first (type, payload, IP), then do the slow work in a queued job. Do not make Safaricom wait on business logic.
It skips the check in daraja.skip_ip_check_in environments, lets through an exact match in daraja.allowed_ips, and otherwise logs Rejected a Daraja request and answers HTTP 200 with C2B00016. It checks the sender's address only: Daraja callbacks carry no signature, so still validate the payload. Behind a proxy or load balancer, configure Laravel's trusted proxies, or every request looks like it comes from the proxy and is rejected.
Body.stkCallback with CheckoutRequestID, ResultCode (0 paid, 1032 cancelled, 1037 timeout, 2001 wrong PIN) and, on success, CallbackMetadata.Item as Name/Value pairs (Amount, MpesaReceiptNumber, PhoneNumber). Read it with collect($items)->pluck('Value', 'Name').TransID, TransAmount, BillRefNumber, BusinessShortCode, MSISDN (which may arrive masked). Refuse a validation with C2B00011 to C2B00016.TransID, or the STK CheckoutRequestID).daraja_phone().ResultCode only when the account reference is unknown.Safaricom cannot reach a local machine. Expose the app through a tunnel, register the tunnel URLs with registerC2BUrls() or registerPullUrl() in the sandbox, and test handlers with $this->postJson('/hooks/daraja/confirmation', $payload), which passes the guard in testing.
Test the guard in a non-skipped environment by setting daraja.skip_ip_check_in to []: an unknown IP gets C2B00016, an allowed IP reaches the controller.
How can I help you explore Laravel packages today?