Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sham Cash Sdk Laravel Package

oktocode/sham-cash-sdk

oktocode/sham-cash-sdk is a lightweight PHP SDK for integrating Sham Cash payments into Laravel applications. Send transfers, verify transactions, and manage wallet operations through a clean API with minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require oktocode/sham-cash-sdk

Requires PHP 8.2+ (the package pins config.platform.php to 8.2.0) and pulls Guzzle 7. There is no Laravel service provider, no facade, and no config file shipped — the package is deliberately framework-free. That means the first 20 minutes of your integration are wiring, not coding:

  1. composer require
  2. Read README.md (and README.ar.md — they are kept in sync) and skim examples/, which ship inside the package.
  3. Register your own binding in AppServiceProvider.

Config

// config/shamcash.php
return [
    'base_url'     => env('SHAMCASH_BASE_URL'),
    'agent_key'    => env('SHAMCASH_AGENT_KEY'),
    'secret_key'   => env('SHAMCASH_SECRET_KEY'), // Base64 of 32 raw bytes
    'callback_url' => env('SHAMCASH_CALLBACK_URL'),
    'redirect_url' => env('SHAMCASH_REDIRECT_URL'),
];

Binding (constructor option names are in the README — confirm them there before you type them):

// AppServiceProvider::register()
$this->app->singleton(\OkToCode\ShamCash\Client::class, function ($app) {
    $logger = $app->make(\Psr\Log\LoggerInterface::class);

    return new \OkToCode\ShamCash\Client(
        config: config('shamcash'),
        logger: $logger,          // optional PSR-3; logs op, status, result code only
        // httpClient: new \Symfony\Component\HttpClient\Psr18Client(), // optional PSR-18
    );
});

Inject Client into controllers, actions, and jobs. The client is stateless and cheap; the singleton just avoids re-reading config.

First use case — create a bill for an order and redirect the user:

public function pay(Order $order, Client $client)
{
    $bill = $client->createBill(
        amount: (string) $order->total,          // decimal string, never a float
        callbackUrl: route('shamcash.webhook'),
        redirectUrl: route('orders.show', $order) . '?ref=' . $order->reference,
    );

    $order->update(['shamcash_bill_no' => $bill->billNo]);

    return redirect($bill->payUrl); // per README; ShamCash does not append billNo
}

Implementation Patterns

1. Wrap the client in a single application service

Don't scatter Client calls. One ShamCashService (or per-operation Action classes) gives you one place for logging, exception mapping, and the money-policy rules below.

final class ShamCashService
{
    public function __construct(private Client $client) {}

    public function createBillFor(Order $order): Bill
    {
        try {
            return $this->client->createBill(
                amount: (string) $order->total,
                callbackUrl: route('shamcash.webhook'),
                redirectUrl: route('orders.show', $order) . '?ref=' . $order->reference,
            );
        } catch (ApiException $e) when ($e->resultCode === ResultCode::DuplicateBill) {
            // 1704 — the bill already exists; fetch it instead of retrying
            return $this->client->getBill($order->shamcash_bill_no);
        }
    }
}

2. Per-call URL overrides, merged independently

The callbackUrl/redirectUrl passed to createBill beat the client defaults independently — you can override one and inherit the other. ShamCash still requires both on the wire; if either is missing after the merge the SDK throws InvalidArgumentException before any HTTP leaves the box. That makes a missing SHAMCASH_CALLBACK_URL a startup-time bug you can catch in a feature test rather than a failed payment.

Because ShamCash does not append billNo to redirectUrl, put your own correlation key in the query string yourself (?ref=…) — you'll need it to find the order when the user lands back.

3. Webhook controller: parse sync, work async

The webhook body is {"encData"} only, and parseWebhook takes the raw body string. Do not touch $request->all() and do not re-encode.

// routes/api.php  (api middleware group: no CSRF, and you don't want a session)
Route::post('/shamcash/webhook', ShamCashWebhookController::class)
    ->name('shamcash.webhook');
public function __invoke(Request $request, Client $client)
{
    try {
        $event = $client->parseWebhook($request->getContent());
    } catch (CryptoException|TransportException|InvalidArgumentException $e) {
        report($e);                       // never log encData or the payload
        return response()->noContent(400);
    }

    // Parse NOW, queue the side effects. exp/iat skew is 30s — a delayed
    // job that re-parses will reject a perfectly good webhook.
    ProcessShamCashEvent::dispatch($event);

    return response()->noContent(200);
}

Have the job re-load the order by billNo inside a DB::transaction() and make the state transition idempotent (where('status','pending')->update(...)) — webhook redelivery is always possible and parseWebhook does not dedupe for you.

4. Refunds: caller-supplied idempotency keys, no retries

The SDK never invents an idempotency key and never retries money calls. Persist the key before you call, and make it derived-but-unique:

$key = "refund:{$order->id}:{$refund->id}"; // must be 10–100 chars
$refund->update(['idempotency_key' => $key]);

try {
    $client->refundBill(idempotencyKey: $key, /* ... */);
} catch (TransportException $e) {
    // Do NOT auto-retry. Re-queue manually with the SAME key.
}

Add a unique index on idempotency_key. A retried refund with the same key is safe; a retried refund with a new key is money.

5. getBill is a fallback, not a poller

Documented policy: one shot, at least 10 minutes after create, only when the webhook never arrived. Implement it as a scheduled command or a delayed job with a guard, not a while loop in a controller.

6. Reconciliation with eachTransaction

eachTransaction is a generator over pages — good for a nightly php artisan shamcash:reconcile command, bad inside a request (each page is another HTTP round trip against a 30s request timeout).

foreach ($client->eachTransaction($filters) as $tx) {
    // upsert into a local transactions table keyed by the transaction id
}

listTransactions gives you one page if you'd rather drive pagination yourself.

7. Exception → domain mapping

Four exception types, one decision each:

Exception Meaning Laravel response
InvalidArgumentException local validation, no HTTP sent bug — fail loudly, log at error
ApiException + ResultCode ShamCash said no (result !== 2500 or succeeded !== true) map 1704 to "already created", others to user-facing failure
TransportException timeout / non-200 / bad JSON retryable for reads, never for money
CryptoException bad secretKey / bad token config error, page on-call

Note the trap: HTTP 200 only means the server parsed the request. Success is result === 2500 and succeeded === true. Never branch on status code.

8. Testing

Laravel's Http::fake() is useless here — the SDK talks to a PSR-18 client, not Illuminate\Http\Client. Inject a stub implementing Psr\Http\Client\ClientInterface that returns canned {"encData": ...} responses (JWE-encode them with a test key), and assert on the four exception paths. Because the SDK validates locally first, you can test your whole URL-merge and amount-validation layer with zero network.


Gotchas and Tips

Money as strings, end to end. Amounts are decimal strings and are written as JSON numbers without a float cast. In Laravel: use a decimal(18,2) or string column, cast to string (not float, not int), and never do (float) $order->total. json_encode of a float can turn 10.10 into 10.099999….

secretKey is Base64 for 32 raw bytes, not the Base64 text interpreted as UTF-8. A base64_decode mistake here surfaces as a CryptoException on every operation, including webhooks — it looks like a network problem but it isn't. Symptom: requests leave, nothing works, nothing in the ShamCash dashboard.

Webhook verification is the JWE, not a signature header. There is no HMAC header to check; parseWebhook decrypts encData and rejects exp older than now + skew and iat more than skew in the future (default skew 30s, token TTL 300s). Practical consequences:

  • Keep the box NTP-synced. A drifting clock silently 400s every webhook.
  • Don't hand the raw body to a queue that might run minutes later — parse on receipt.
  • Pass the raw string, $request->getContent(). json_decode + json_encode round-tripping breaks the AAD.

No retries on money calls — by design. createBill and refundBill will not retry. createBill repeated can return 1704; catch it and call getBill. refundBill repeated requires the same idempotency key. Configure your queued refund job with $tries = 1 and explicit manual requeue, or you'll ship double refunds.

Don't poll getBill. It's a one-shot at T+10min. A while (!$paid) sleep() loop is the most common wrong integration and will get rate-limited.

TLS stays on, even with an injected client. The SDK doesn't disable verification. If your local/dev PSR-18 client is configured to skip verification, that's your choice and your incident — don't ship that config.

Timeouts are fixed at 5s connect / 30s request. If your queue worker's retry_after is under ~35s you can double-process; set --timeout above 30 and retry_after above timeout.

Logging leaks are easy here. The injected PSR-3 logger records operation, HTTP status, and result code — never secretKey, encData, or a decrypted payload. That only holds if you don't add Laravel's request/response logging on the webhook route, don't Log::info($request->all()), and don't report() an exception whose message embeds the payload. Audit your webhook route's middleware stack.

Don't touch Crypto, Http, or Support. They're @internal. Everything you need is on Client — models, enums, and exceptions are the supported surface.

Config caching. env() calls outside config/ return null once you run config:cache. Read credentials only in config/shamcash.php.

Version pinning. Packagist reads git tags; v1.0.0 → 1.0.0. A path install of master is dev-master. There's no version field in composer.json, so don't be surprised when a VCS/path repo resolves oddly.

Namespace is OkToCode\ShamCash, package is oktocode/sham-cash-sdk. The company is OkToCode (ok2code.com), but the namespace is not ok2code — a rename will break your autoload and any future bridge package.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource