PHPackages                             digitaltunnel/jeelpay - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. digitaltunnel/jeelpay

ActiveLibrary[Payment Processing](/categories/payments)

digitaltunnel/jeelpay
=====================

JeelPay (Study Now Pay Later) SDK for Laravel — Items + Schooling checkout, refunds, OAuth token caching, signed webhooks.

v1.0.1(2w ago)046MITPHPPHP ^8.2CI passing

Since May 28Pushed 2w agoCompare

[ Source](https://github.com/digital-tunnel/jeelpay)[ Packagist](https://packagist.org/packages/digitaltunnel/jeelpay)[ RSS](/packages/digitaltunnel-jeelpay/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (24)Versions (3)Used By (0)

JeelPay for Laravel
===================

[](#jeelpay-for-laravel)

 [![PHP 8.2+](https://camo.githubusercontent.com/9b17cedededf472a96b8cadcf9b0ed7f567169f80143bc35c87aea831d5201fd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e322532422d373737626234)](https://camo.githubusercontent.com/9b17cedededf472a96b8cadcf9b0ed7f567169f80143bc35c87aea831d5201fd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e322532422d373737626234) [![Laravel 11–13](https://camo.githubusercontent.com/2f81b61b3d2b5687f14be3bf72bec096ce8be177062ce1aba36eaaff41459372/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d31312532307c25323031322532307c25323031332d666632643230)](https://camo.githubusercontent.com/2f81b61b3d2b5687f14be3bf72bec096ce8be177062ce1aba36eaaff41459372/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d31312532307c25323031322532307c25323031332d666632643230) [![Tests Pest](https://camo.githubusercontent.com/2aec229b56291ec3a07bf139bc35835970f62496f251cfd634002a2805000000/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d706573742d326561343466)](https://camo.githubusercontent.com/2aec229b56291ec3a07bf139bc35835970f62496f251cfd634002a2805000000/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d706573742d326561343466) [![PHPStan level 6](https://camo.githubusercontent.com/6260b9f10635192f2139ab2bc42751142251ed05e7f79ab1e49910e95b1c6daf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068707374616e2d6c6576656c253230362d626c7565)](https://camo.githubusercontent.com/6260b9f10635192f2139ab2bc42751142251ed05e7f79ab1e49910e95b1c6daf/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068707374616e2d6c6576656c253230362d626c7565) [![License MIT](https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e)](https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e)

A typed, testable SDK for [JeelPay](https://jeel.co) — Saudi Arabia's "Study Now Pay Later" platform — with first-class Laravel integration. Supports both **Items** (universities, courses, training centers) and **Schooling** (K-12) checkout flows out of the box.

> Built against the official [JeelPay developer docs](https://docs.jeel.co/). Endpoints, payloads, statuses and webhook signatures are verified to match the documented API.

- OAuth 2.1 client credentials with **mandatory token caching** (Laravel Cache, configurable store)
- Fluent request builders for Items + Schooling checkouts with built-in Saudi-specific validation
- Readonly DTOs (`CheckoutResult`, `CheckoutStatusResult`, `RefundResult`, `WebhookPayload`)
- Auto-generated idempotency keys (UUID v4) on every checkout POST — reuse on retries to dedupe
- `tx_id` captured from response headers on every call for support debugging
- Auto-registered webhook endpoint outside the `web` middleware group — no CSRF dance
- HMAC-SHA256 signature verification on incoming webhooks
- Idempotent webhook de-duplication via fingerprint hash
- Optional `jeelpay_checkouts`, `jeelpay_refunds`, `jeelpay_webhook_events` tables — auto-mirrored
- Test helper `JeelPayFake` with realistic fixtures + signed-webhook builder

---

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 11, 12, or 13

Installation
------------

[](#installation)

```
composer require digitaltunnel/jeelpay
```

Publish the config:

```
php artisan vendor:publish --tag=jeelpay-config
```

Publish &amp; run the migrations (opt-in):

```
php artisan vendor:publish --tag=jeelpay-migrations
php artisan migrate
```

Configure your `.env`:

```
JEELPAY_ENV=sandbox                  # or "production"
JEELPAY_CLIENT_ID=...
JEELPAY_CLIENT_SECRET=...

# Optional — defaults are sandbox/production base URLs derived from JEELPAY_ENV
# JEELPAY_API_URL=https://api.sandbox.jeel.co
# JEELPAY_AUTH_URL=https://auth.sandbox.jeel.co

# Optional — used as request fallbacks when the builder doesn't set them
JEELPAY_REDIRECT_URL=https://yourapp.com/checkout/result
JEELPAY_NOTIFICATION_URL=https://yourapp.com/webhooks/jeelpay
```

Verify the webhook route is live:

```
php artisan route:list --name=jeelpay.webhook
# POST  webhooks/jeelpay ... VerifyWebhookSignature
```

---

Quickstart
----------

[](#quickstart)

### Items checkout (universities, institutes, training centers)

[](#items-checkout-universities-institutes-training-centers)

```
use DigitalTunnel\JeelPay\Facades\JeelPay;
use DigitalTunnel\JeelPay\Requests\ItemsCheckoutRequest;
use DigitalTunnel\JeelPay\ValueObjects\Buyer;
use DigitalTunnel\JeelPay\ValueObjects\Item;
use DigitalTunnel\JeelPay\ValueObjects\Urls;

$result = JeelPay::checkouts()->createItems(
    ItemsCheckoutRequest::make()
        ->buyer(Buyer::make(
            firstName: 'Essa',
            lastName: 'Alshammari',
            mobileNumber: '512345678',          // Saudi format, 9 digits, starts with 5
            email: 'essa@example.com',
            nationalId: '1234567890',            // 10 digits, starts with 1 or 2
        ))
        ->addItem(Item::make(
            name: 'Data Science Diploma',
            quantity: 1,
            totalCost: 3500.00,
            referenceId: 'course_456',
            unitPrice: 3500.00,
            entityId: 'uuid-of-entity',          // required for multi-entity groups
        ))
        ->urls(Urls::make(
            redirectUrl: route('checkout.return'),
            notificationUrl: route('jeelpay.webhook'),
        ))
        ->referenceId('order_1234')
        ->metadata(['student_id' => 'STU-001']),
);

// $result->idempotencyKey is auto-generated. Save it before redirecting if you
// want safe retries.
return redirect($result->redirectUrl);
```

### Schooling checkout (K-12)

[](#schooling-checkout-k-12)

```
use DigitalTunnel\JeelPay\Requests\SchoolingCheckoutRequest;
use DigitalTunnel\JeelPay\ValueObjects\Student;

// Get the active educational year IDs (auth handled for you)
$years = JeelPay::public()->educationalYears();

$result = JeelPay::checkouts()->createSchooling(
    SchoolingCheckoutRequest::make()
        ->buyer(Buyer::make('Zayed', 'Al-Abbad', '512345678'))
        ->addStudent(Student::make(
            name: 'Nasser Al-jbreen',
            nationalId: '2098765432',
            entityId: 'uuid-of-school-entity',
            educationalYearId: $years[0]->id,
            cost: 8500.00,
            referenceId: 'student_grade5_001',
        ))
        ->urls(Urls::make(
            redirectUrl: route('checkout.return'),
            notificationUrl: route('jeelpay.webhook'),
        ))
        ->referenceId('enrollment_2026_0012')
        ->metadata(['grade' => '5', 'academic_year' => '2026-2027']),
);

return redirect($result->redirectUrl);
```

### Idempotent retries

[](#idempotent-retries)

If your network call timed out, retry with the **same** idempotency key:

```
$key = (string) Str::uuid();
// save $key to your order record FIRST so retries can find it

try {
    $result = JeelPay::checkouts()->createItems($request, idempotencyKey: $key);
} catch (\DigitalTunnel\JeelPay\Exceptions\ConnectionException) {
    // Retry with the same key — JeelPay returns the cached response, no duplicate.
    $result = JeelPay::checkouts()->createItems($request, idempotencyKey: $key);
}
```

### Status &amp; refunds

[](#status--refunds)

```
use DigitalTunnel\JeelPay\Enums\RefundStatus;

$status = JeelPay::checkouts()->find('checkout-uuid');

if ($status->isPaid()) {
    // SUCCEEDED — buyer paid down payment, installment plan created
}

// Submit a refund (plan withdrawal). Only SUCCEEDED checkouts can be refunded.
// amount + reason are required by JeelPay; referenceId is an optional business ref.
$refund = JeelPay::refunds()->submit(
    installmentRequestId: 'checkout-uuid',
    amount: 3500.00,
    reason: 'Customer cancelled enrollment',
    referenceId: 'refund_order_1234',
);

if ($refund->isDone()) {
    // DONE — processed immediately
} elseif ($refund->status === RefundStatus::Pending) {
    // PENDING — under review; poll later
}

// Poll status later by the withdrawal id ($refund->id)
$refund = JeelPay::refunds()->find($refund->id);

if ($refund->isRejected()) {
    Log::warning('Refund rejected', ['reason' => $refund->rejectionReason]);
}
```

---

Webhooks
--------

[](#webhooks)

The package auto-registers `POST {config.webhook.path}` (default `webhooks/jeelpay`) **outside** the `web` middleware group, so CSRF is never applied.

Listen for typed events:

```
use DigitalTunnel\JeelPay\Events\CheckoutSucceeded;
use DigitalTunnel\JeelPay\Events\CheckoutRejected;
use DigitalTunnel\JeelPay\Events\CheckoutExpired;

Event::listen(CheckoutSucceeded::class, function (CheckoutSucceeded $event) {
    // $event->payload is a DigitalTunnel\JeelPay\DTOs\WebhookPayload
    $order = Order::firstWhere('jeelpay_checkout_id', $event->payload->checkoutId);
    $order->markPaid();
});
```

Available events:

- `CheckoutPending` · `CheckoutSucceeded` · `CheckoutRejected` · `CheckoutExpired`
- `WebhookReceived` (catch-all, fires for every webhook)

### Signature verification

[](#signature-verification)

The middleware computes `base64(HMAC-SHA256(client_secret, raw_body))` over the **raw** request body and compares it (timing-safe) against the `X-Jeel-Signature` header. **The raw body is preserved**through `$request->getContent()` — never re-serialise the JSON before checking.

### Idempotent de-duplication

[](#idempotent-de-duplication)

When `jeelpay.persistence.webhook_events.enabled` is true (default), the controller stores every webhook keyed by a fingerprint of `(checkout_id|status|raw_body)`. If JeelPay retries the exact same event, no duplicate event is dispatched.

### Disabling auto-registration

[](#disabling-auto-registration)

```
JEELPAY_WEBHOOK_ENABLED=false
```

Then register your own route:

```
Route::post('my-custom-path', \DigitalTunnel\JeelPay\Http\Controllers\WebhookController::class)
    ->middleware([\DigitalTunnel\JeelPay\Http\Middleware\VerifyWebhookSignature::class])
    ->name('my.webhook');
```

---

Persistence (opt-in)
--------------------

[](#persistence-opt-in)

Three tables ship via `--tag=jeelpay-migrations`. Each layer has an independent enable flag.

### `jeelpay_webhook_events`

[](#jeelpay_webhook_events)

Fingerprint-based audit + idempotency log. Auto-written by the controller when `JEELPAY_LOG_WEBHOOK_EVENTS=true`.

### `jeelpay_checkouts`

[](#jeelpay_checkouts)

Local mirror of every checkout (created by `createItems()`/`createSchooling()`/`find()` and updated by webhooks). Enable with `JEELPAY_PERSIST_CHECKOUTS=true`. Polymorphic `payable_*`columns let you link a checkout to any of your domain models.

```
use DigitalTunnel\JeelPay\Models\JeelPayCheckout;

$checkout = JeelPayCheckout::query()->where('checkout_id', $id)->first();
$checkout->payable()->associate($order)->save();
$checkout->statusEnum();        // CheckoutStatus::Succeeded
$checkout->isPaid();
```

### `jeelpay_refunds`

[](#jeelpay_refunds)

Mirror of every refund call. Enable with `JEELPAY_PERSIST_REFUNDS=true`.

---

Token caching
-------------

[](#token-caching)

The OAuth endpoint is rate-limited. The package caches tokens in your default Laravel cache store (override with `JEELPAY_CACHE_STORE`) under the key `jeelpay.access_token`, refreshing 30 seconds before expiry. Tokens are also memoised per request to avoid re-deserialising on every call.

```
JeelPay::auth()->token();      // returns DTOs\AccessToken
JeelPay::auth()->refresh();    // force re-mint (after a 401)
JeelPay::auth()->forget();     // clear cache + memoised
```

---

Exception handling
------------------

[](#exception-handling)

```
use DigitalTunnel\JeelPay\Exceptions\ValidationException;
use DigitalTunnel\JeelPay\Exceptions\AuthenticationException;
use DigitalTunnel\JeelPay\Exceptions\IdempotencyConflictException;
use DigitalTunnel\JeelPay\Exceptions\ConnectionException;

try {
    JeelPay::checkouts()->createItems($request);
} catch (ValidationException $e) {
    // 400 — JeelPay-native errors:  $e->errors()  /  $e->firstErrorMessage()
    Log::warning('JeelPay validation', [
        'tx_id' => $e->txId(),
        'errors' => $e->errors(),
    ]);
} catch (IdempotencyConflictException $e) {
    // IDEMPOTENCY-001 — concurrent duplicate; wait and retry
} catch (AuthenticationException $e) {
    // 401 — credentials issue
} catch (ConnectionException $e) {
    // network/timeout — safe to retry with the same idempotency key
}
```

The full hierarchy:

```
JeelPayException                       (base — RuntimeException)
├── ApiException                       (any non-2xx — carries statusCode, txId, errors)
│   ├── AuthenticationException        (401)
│   ├── ValidationException            (400/422)
│   ├── NotFoundException              (404)
│   ├── ServerException                (5xx)
│   └── IdempotencyConflictException   (IDEMPOTENCY-001)
├── ConnectionException                (network/timeout)
└── InvalidWebhookSignatureException   (signature mismatch)

```

Every `ApiException` exposes `txId()` — include it in your support tickets.

---

Configuration reference
-----------------------

[](#configuration-reference)

Every option in `config/jeelpay.php` is environment-driven. The full list:

Env varDefaultPurpose`JEELPAY_ENV``sandbox``sandbox` or `production` — selects the base URLs below.`JEELPAY_CLIENT_ID`—OAuth client id (required).`JEELPAY_CLIENT_SECRET`—OAuth client secret (required). Also used to verify webhook signatures.`JEELPAY_API_URL`per-envOverride the API host (e.g. a local proxy/mock).`JEELPAY_AUTH_URL`per-envOverride the OAuth host.`JEELPAY_TIMEOUT``30`HTTP timeout in seconds (auth + API).`JEELPAY_RETRY_TIMES``0`Automatic retries on transient HTTP failures.`JEELPAY_RETRY_SLEEP_MS``200`Delay between retries (ms).`JEELPAY_CACHE_STORE`default storeCache store for the access token (`redis` recommended in prod).`JEELPAY_CACHE_KEY``jeelpay.access_token`Token cache key.`JEELPAY_TOKEN_REFRESH_BUFFER``30`Seconds before expiry to proactively refresh.`JEELPAY_REDIRECT_URL`—Default `redirect_url` fallback for checkouts.`JEELPAY_NOTIFICATION_URL`—Default `notification_url` fallback for checkouts.`JEELPAY_WEBHOOK_ENABLED``true`Auto-register the webhook route.`JEELPAY_WEBHOOK_PATH``webhooks/jeelpay`Webhook route path.`JEELPAY_WEBHOOK_NAME``jeelpay.webhook`Webhook route name.`JEELPAY_LOG_WEBHOOK_EVENTS``true`Persist + de-dupe webhooks in `jeelpay_webhook_events`.`JEELPAY_PERSIST_CHECKOUTS``false`Mirror checkouts into `jeelpay_checkouts`.`JEELPAY_PERSIST_REFUNDS``false`Mirror refunds into `jeelpay_refunds`.The base URLs (sandbox → production):

API hostAuth hostSandbox`https://api.sandbox.jeel.co``https://auth.sandbox.jeel.co`Production`https://api.jeel.co``https://auth.jeel.co`Statuses, enums &amp; DTOs
--------------------------

[](#statuses-enums--dtos)

**`CheckoutStatus`** (`->find()->status`, webhook `$payload->status`):

CaseValueMeaning`Pending``PENDING`Created, awaiting buyer action.`Succeeded``SUCCEEDED`Down payment paid, installment plan created.`Rejected``REJECTED`Rejected / cancelled / not eligible.`Expired``EXPIRED`No action for 2 hours.Helpers: `$status->isTerminal()`, `$status->isPaid()`.

**`RefundStatus`** (`$refund->status`):

CaseValueMeaning`Pending``PENDING`Under review.`Done``DONE`Successfully processed.`Rejected``REJECTED`Rejected (see `$refund->rejectionReason`).Helpers: `$status->isTerminal()`, `$status->isDone()`, `$status->isRejected()`.

**Result DTOs** (all `readonly`):

- `CheckoutResult` → `checkoutId`, `redirectUrl`, `type`, `idempotencyKey`, `referenceId`, `txId`, `metadata`, `raw`
- `CheckoutStatusResult` → `checkoutId`, `status`, `type`, `referenceId`, `txId`, `metadata`, `raw` + `isPaid()/isPending()/isExpired()/isRejected()`
- `RefundResult` → `id` (withdrawalRequestId), `status`, `rejectionReason`, `referenceId`, `checkoutId`, `txId`, `raw` + `isDone()/isRejected()`
- `WebhookPayload` → `checkoutId`, `status`, `type`, `referenceId`, `metadata`, `rawBody`, `raw`

Testing
-------

[](#testing)

The package ships `JeelPayFake` to stub every JeelPay HTTP call (auth, checkouts, refunds) and to build signed webhooks — no network access needed.

```
use DigitalTunnel\JeelPay\Testing\JeelPayFake;

it('creates an items checkout', function () {
    JeelPayFake::fakeAuth();
    JeelPayFake::fakeItemsCheckoutCreated();

    $this->post('/orders', [...])->assertRedirect();

    JeelPayFake::assertSentTo('https://api.sandbox.jeel.co/v3/checkout');
});

it('processes a refund', function () {
    JeelPayFake::fakeAuth();
    JeelPayFake::fakeRefundSubmitted(status: 'DONE');

    $refund = JeelPay::refunds()->submit('chk_x', 3500.00, 'Cancelled');

    expect($refund->isDone())->toBeTrue();
});

it('handles a SUCCEEDED webhook', function () {
    $signed = JeelPayFake::signedWebhook(
        secret: config('jeelpay.client_secret'),
        checkoutId: 'chk_x',
        status: 'SUCCEEDED',
    );

    $this->call(
        method: 'POST',
        uri: route('jeelpay.webhook'),
        server: $this->transformHeadersToServerVars($signed['headers']),
        content: $signed['body'],
    )->assertNoContent();
});
```

Available `JeelPayFake` helpers: `fakeAuth()`, `fakeItemsCheckoutCreated()`, `fakeSchoolingCheckoutCreated()`, `fakeRefundSubmitted()`, `fakeRefundStatus()`, `fakeValidationError()`, `sign()`, `signedWebhook()`, `assertSentTo()`, `assertNothingSent()`, `assertSentCount()`.

Contributing &amp; quality gate
-------------------------------

[](#contributing--quality-gate)

```
composer install

composer test        # run the Pest suite
composer lint        # auto-fix code style (Laravel Pint)
composer lint:test   # check style without writing
composer analyse     # PHPStan / Larastan, level 6
composer check       # lint:test + analyse + test (CI gate)
```

The suite runs against an in-memory SQLite database via Orchestra Testbench — no setup required.

Sandbox info
------------

[](#sandbox-info)

- API: `https://api.sandbox.jeel.co`
- Auth: `https://auth.sandbox.jeel.co`
- Test card: `4111 1111 1111 1111`, exp `05/30`, CVV `123`, 3DS code `Checkout1!`
- Test buyer phone: any valid Saudi mobile (e.g. `512345678`), OTP `3333`, passcode `100000`

License
-------

[](#license)

MIT.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance96

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~39 days

Total

2

Last Release

17d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/66947877?v=4)[Hossam H Monir](/maintainers/HossamMonir)[@hossammonir](https://github.com/hossammonir)

---

Top Contributors

[![hossammonir](https://avatars.githubusercontent.com/u/66947877?v=4)](https://github.com/hossammonir "hossammonir (2 commits)")

---

Tags

laravelpaymentseducationbnplsaudijeelpaysnpl

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/digitaltunnel-jeelpay/health.svg)

```
[![Health](https://phpackages.com/badges/digitaltunnel-jeelpay/health.svg)](https://phpackages.com/packages/digitaltunnel-jeelpay)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k15.1M136](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M133](/packages/roots-acorn)[laravel/cashier

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.

2.5k30.2M151](/packages/laravel-cashier)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

77922.3M186](/packages/laravel-mcp)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
