Looking to hire Laravel developers? Try LaraJobs

laravel-snippe maintained by shadrackjm

Description
A Laravel package for integrating Snippe Tanzania payment gateway — Mobile Money, Cards, and QR payments.
Author
Last update
2026/04/11 10:06 (dev-main)
License
Downloads
11

Comments
comments powered by Disqus

Laravel Snippe

Latest Version on Packagist PHP Version Laravel License

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_SECRET in 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.