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.
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:
composer requireREADME.md (and README.ar.md — they are kept in sync) and skim examples/, which ship inside the package.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
}
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);
}
}
}
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.
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.
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.
getBill is a fallback, not a pollerDocumented 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.
eachTransactioneachTransaction 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.
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.
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.
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:
$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.
How can I help you explore Laravel packages today?