laravel-snippe maintained by shadrackjm
Laravel Snippe
A clean, expressive Laravel package for integrating the Snippe Tanzania payment gateway. Supports Mobile Money (Airtel, M-Pesa, Halotel, Mixx), Card payments, Dynamic QR codes, and Payment Sessions (hosted checkout) — with webhook signature verification and a fluent builder API.
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0 | ^12.0 |
Installation
composer require shadrackjm/laravel-snippe
Package discovery is automatic — no need to manually register the service provider.
Publish the config file
php artisan vendor:publish --tag=snippe-config
This creates config/snippe.php in your application.
Add credentials to .env
SNIPPE_API_KEY=snp_your_live_key_here
SNIPPE_BASE_URL=https://api.snippe.sh/v1
SNIPPE_WEBHOOK_URL=https://yourapp.com/snippe/webhook
SNIPPE_WEBHOOK_SECRET=your_webhook_signing_secret
SNIPPE_WEBHOOK_PATH=snippe/webhook
SNIPPE_CURRENCY=TZS
SNIPPE_TIMEOUT=30
Find
SNIPPE_WEBHOOK_SECRETin your Snippe Dashboard → Webhooks → Signing Secret.
Exclude webhook route from CSRF
In Laravel 11 / 12 (bootstrap/app.php):
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'snippe/*',
]);
})
Quick Start
use ShadrackJm\Snippe\Facades\Snippe;
// Mobile Money — USSD push sent to customer's phone
$payment = Snippe::mobileMoney(5000, '0754123456')
->customer('John Doe', 'john@email.com')
->send();
echo $payment->reference(); // "9015c155-9e29-..."
echo $payment->status(); // "pending"
Usage
Payment Sessions (Hosted Checkout) — Recommended for Subscriptions
A Payment Session creates a Snippe-hosted checkout page. Redirect your customer there to pay. This is the cleanest approach for subscription and one-off payments.
use ShadrackJm\Snippe\Facades\Snippe;
$session = Snippe::session(50000)
->customer('John Doe', 'john@email.com')
->allowedMethods(['mobile_money', 'card'])
->redirectTo(
route('subscription.success'),
route('subscription.cancel')
)
->webhook(route('snippe.webhook'))
->metadata(['plan_id' => 3, 'company_id' => 12])
->expiresIn(1800) // 30 minutes
->send();
// Redirect the customer to the Snippe-hosted checkout page
return redirect($session->checkoutUrl());
When payment completes, Snippe fires your webhook with the metadata you attached, allowing you to activate the subscription on the server side.
Allow custom amount (e.g. top-ups)
$session = Snippe::session()
->allowCustomAmount()
->customer('John Doe', 'john@email.com')
->redirectTo(route('topup.success'))
->send();
return redirect($session->checkoutUrl());
Mobile Money
$payment = Snippe::mobileMoney(5000, '0754123456')
->customer('John Doe', 'john@email.com')
->webhook('https://yourapp.com/snippe/webhook')
->metadata(['order_id' => 'ORD-123'])
->description('Order #123 from My Shop')
->idempotencyKey('order-123-attempt-1')
->send();
// The customer receives a USSD push. You get a webhook when done.
Card Payment
$payment = Snippe::card(10000)
->phone('0754123456')
->customer('Jane Doe', 'jane@email.com')
->billing('123 Main Street', 'Dar es Salaam', 'DSM', '14101', 'TZ')
->redirectTo('https://yourapp.com/success', 'https://yourapp.com/cancel')
->send();
// Redirect the customer to the secure checkout page
return redirect($payment->paymentUrl());
Dynamic QR Code
$payment = Snippe::qr(5000)
->customer('John Doe', 'john@email.com')
->redirectTo('https://yourapp.com/success', 'https://yourapp.com/cancel')
->send();
$qrData = $payment->qrCode(); // Render as a QR image
$paymentUrl = $payment->paymentUrl(); // Or redirect to hosted page
Raw Array Payloads
// Generic payment with a full array
$payment = Snippe::createPayment([
'payment_type' => 'mobile',
'details' => ['amount' => 5000, 'currency' => 'TZS'],
'phone_number' => '255754123456',
'customer' => ['firstname' => 'John', 'lastname' => 'Doe', 'email' => 'john@mail.com'],
]);
// Convenience wrapper for mobile payments
$payment = Snippe::initiateMobilePayment([
'amount' => 5000,
'phone_number' => '0754123456',
'customer' => ['firstname' => 'John', 'lastname' => 'Doe', 'email' => 'john@mail.com'],
]);
Payment Operations
Verify / Check Status
$payment = Snippe::verifyTransaction('9015c155-9e29-4e8e-8fe6-d5d81553c8e6');
// Or alias:
$payment = Snippe::find('9015c155-...');
echo $payment->status(); // "completed"
echo $payment->amount(); // 5000
echo $payment->completedAt(); // "2026-01-25T00:50:44Z"
List Payments
$result = Snippe::payments(limit: 20, offset: 0);
Account Balance
$balance = Snippe::balance();
$available = $balance['data']['available']['value']; // e.g. 6943
$currency = $balance['data']['available']['currency']; // "TZS"
Retry USSD Push
Snippe::push('payment-reference-uuid');
Snippe::push('payment-reference-uuid', '+255787654321'); // Different number
Payment Object Reference
| Method | Returns | Description |
|---|---|---|
reference() |
?string |
Unique payment UUID |
status() |
?string |
pending | completed | failed | expired | voided |
paymentType() |
?string |
mobile | card | dynamic-qr |
amount() |
?int |
Payment amount |
currency() |
?string |
TZS |
isPending() |
bool |
|
isCompleted() |
bool |
|
isFailed() |
bool |
|
isExpired() |
bool |
|
isVoided() |
bool |
|
paymentUrl() |
?string |
Checkout URL (card/QR) |
qrCode() |
?string |
QR data string |
fees() |
?int |
Transaction fees (post-completion) |
netAmount() |
?int |
Amount after fees |
customer() |
array |
Customer info |
completedAt() |
?string |
Completion timestamp |
createdAt() |
?string |
Creation timestamp |
toArray() |
array |
Full raw response |
Payment Session Object Reference
| Method | Returns | Description |
|---|---|---|
reference() |
?string |
Session reference e.g. sess_abc123 |
status() |
?string |
pending | completed | expired | cancelled |
checkoutUrl() |
?string |
Hosted checkout page URL — redirect customer here |
paymentLinkUrl() |
?string |
Shareable short link |
shortCode() |
?string |
Abbreviated session code |
amount() |
?int |
Fixed session amount |
currency() |
?string |
TZS |
allowCustomAmount() |
bool |
Whether customer can enter their own amount |
isPending() |
bool |
|
isCompleted() |
bool |
|
isExpired() |
bool |
|
isCancelled() |
bool |
|
expiresAt() |
?string |
Session expiry timestamp |
completedAt() |
?string |
Completion timestamp |
toArray() |
array |
Full raw response |
Phone Number Normalization
The builder automatically normalises any Tanzanian number format:
0754123456 → 255754123456 ✓ local
+255754123456 → 255754123456 ✓ international with +
255754123456 → 255754123456 ✓ already normalised
754123456 → 255754123456 ✓ no prefix
0754 123 456 → 255754123456 ✓ with spaces
0754-123-456 → 255754123456 ✓ with dashes
Webhooks
Automatic Route
The package registers POST /snippe/webhook automatically. Point your Snippe dashboard to:
https://yourapp.com/snippe/webhook
Signature Verification
Always verify the webhook signature in production to prevent spoofed events:
use ShadrackJm\Snippe\Webhook;
public function handle(Request $request): JsonResponse
{
$event = Webhook::fromRaw($request->getContent(), $request->headers->all());
// Throws SnippeException (401) if signature is invalid
$event->verifyOrFail(config('snippe.webhook_secret'));
if ($event->isPaymentCompleted()) {
// Activate subscription, fulfil order, etc.
$meta = $event->metadata(); // ['plan_id' => 3, 'company_id' => 12]
}
return response()->json(['received' => true]);
}
Or use the boolean form:
if (! $event->verify(config('snippe.webhook_secret'))) {
return response()->json(['error' => 'Invalid signature'], 401);
}
Listening to Events
The built-in webhook controller fires Laravel events you can listen to:
// In a service provider boot() method
Event::listen('snippe.payment.completed', function (array $data) {
$subscription = Subscription::where('payment_ref', $data['reference'])->firstOrFail();
$subscription->activate();
});
Event::listen('snippe.payment.failed', function (array $data) {
// Handle failure — notify the user, etc.
});
The $data array contains: reference, amount, currency, customer, metadata, payload.
Custom Webhook Controller
Extend the built-in controller to add your own logic:
// app/Http/Controllers/SnippeWebhookController.php
namespace App\Http\Controllers;
use ShadrackJm\Snippe\Http\Controllers\WebhookController;
use ShadrackJm\Snippe\Webhook;
class SnippeWebhookController extends WebhookController
{
protected function onPaymentCompleted(Webhook $event): void
{
// Verify signature first
$event->verifyOrFail(config('snippe.webhook_secret'));
$meta = $event->metadata();
// Activate the subscription, fulfil the order, etc.
}
protected function onPaymentFailed(Webhook $event): void
{
// Notify user of failure
}
}
Then in bootstrap/app.php set SNIPPE_WEBHOOK_PATH=false and register your own route:
// routes/web.php
Route::post('/snippe/webhook', [SnippeWebhookController::class, 'handle'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
Manual Webhook Handling
use ShadrackJm\Snippe\Webhook;
public function handle(Request $request): JsonResponse
{
$event = Webhook::fromRaw($request->getContent(), $request->headers->all());
$event->verifyOrFail(config('snippe.webhook_secret'));
if ($event->isPaymentCompleted()) {
// $event->reference(), $event->amount(), $event->metadata() ...
}
return response()->json(['received' => true]);
}
Error Handling
All API errors throw a SnippeException:
use ShadrackJm\Snippe\Exceptions\SnippeException;
try {
$session = Snippe::session(50000)
->customer('John Doe', 'john@email.com')
->send();
} catch (SnippeException $e) {
$e->getMessage(); // "amount must be at least 500"
$e->getCode(); // 422 (HTTP status)
$e->getErrorCode(); // "validation_error"
$e->getResponse(); // full API error array
}
| HTTP | Error Code | Meaning |
|---|---|---|
| 400 | validation_error |
Missing/invalid field |
| 401 | unauthorized |
Bad or missing API key |
| 403 | insufficient_scope |
API key lacks permission |
| 404 | not_found |
Payment/session not found |
| 422 | validation_error |
Unprocessable entity |
Testing
The package includes a full Pest test suite.
composer test
In your own application, use Laravel's Http::fake():
use Illuminate\Support\Facades\Http;
use ShadrackJm\Snippe\Facades\Snippe;
it('creates a payment session', function () {
Http::fake([
'*/sessions' => Http::response([
'data' => [
'reference' => 'sess_test123',
'status' => 'pending',
'checkout_url' => 'https://checkout.snippe.sh/sess_test123',
],
], 201),
]);
$session = Snippe::session(50000)
->customer('John Doe', 'john@email.com')
->send();
expect($session->isPending())->toBeTrue()
->and($session->checkoutUrl())->toContain('sess_test123');
});
Configuration Reference
// config/snippe.php
return [
'api_key' => env('SNIPPE_API_KEY', ''),
'base_url' => env('SNIPPE_BASE_URL', 'https://api.snippe.sh/v1'),
'webhook_url' => env('SNIPPE_WEBHOOK_URL', null),
'webhook_secret' => env('SNIPPE_WEBHOOK_SECRET', null),
'webhook_path' => env('SNIPPE_WEBHOOK_PATH', 'snippe/webhook'),
'timeout' => env('SNIPPE_TIMEOUT', 30),
'currency' => env('SNIPPE_CURRENCY', 'TZS'),
];
License
MIT — see LICENSE.