digitaltunnel / jeelpay
JeelPay (Study Now Pay Later) SDK for Laravel — Items + Schooling checkout, refunds, OAuth token caching, signed webhooks.
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
A typed, testable SDK for JeelPay — 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. 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_idcaptured from response headers on every call for support debugging- Auto-registered webhook endpoint outside the
webmiddleware 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_eventstables — auto-mirrored - Test helper
JeelPayFakewith realistic fixtures + signed-webhook builder
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
Installation
composer require digitaltunnel/jeelpay
Publish the config:
php artisan vendor:publish --tag=jeelpay-config
Publish & 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
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)
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
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 & 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
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·CheckoutExpiredWebhookReceived(catch-all, fires for every webhook)
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
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
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)
Three tables ship via --tag=jeelpay-migrations. Each layer has an independent enable flag.
jeelpay_webhook_events
Fingerprint-based audit + idempotency log. Auto-written by the controller when
JEELPAY_LOG_WEBHOOK_EVENTS=true.
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
Mirror of every refund call. Enable with JEELPAY_PERSIST_REFUNDS=true.
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
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
Every option in config/jeelpay.php is environment-driven. The full list:
| Env var | Default | Purpose |
|---|---|---|
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-env | Override the API host (e.g. a local proxy/mock). |
JEELPAY_AUTH_URL |
per-env | Override 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 store | Cache 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 host | Auth host | |
|---|---|---|
| Sandbox | https://api.sandbox.jeel.co |
https://auth.sandbox.jeel.co |
| Production | https://api.jeel.co |
https://auth.jeel.co |
Statuses, enums & DTOs
CheckoutStatus (->find()->status, webhook $payload->status):
| Case | Value | Meaning |
|---|---|---|
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):
| Case | Value | Meaning |
|---|---|---|
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,rawCheckoutStatusResult→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
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 & 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
- API:
https://api.sandbox.jeel.co - Auth:
https://auth.sandbox.jeel.co - Test card:
4111 1111 1111 1111, exp05/30, CVV123, 3DS codeCheckout1! - Test buyer phone: any valid Saudi mobile (e.g.
512345678), OTP3333, passcode100000
License
MIT.