PHP SDK

anis-ly/partners — for PHP 8.2 to 8.5, with any PSR-18 HTTP client. Plain PHP, Laravel and Symfony all work.

An optional helper, provided as is

Every Anis SDK is provided free of charge under the MIT License, “as is”, without warranty of any kind. Anis (Aniscom for Technical Services) accepts no responsibility or liability for its use or for any loss arising from it. You remain responsible for your own integration — recording orders before you send them, recovery, key custody and testing. The source code is public: read it to understand exactly what it does before you rely on it. You do not need an SDK — you can call the Anis Partner API directly using these guides and the API reference.

SDK 2.2.0. SDK 1.x is deprecated; see the changelog.

Install

composer require anis-ly/partners:2.2.0 guzzlehttp/guzzle

The SDK finds the PSR-18 client and PSR-17 factories you have installed — Guzzle, or Symfony HTTP Client with nyholm/psr7. Set connection and request timeouts on that client, and turn off redirect following: a signed request must never be sent again to another address.

Configure

use Anis\Partners\AnisPartnersClient;
use Anis\Partners\ClientOptions;
use Anis\Partners\Signing\PemP256Signer;

$options = ClientOptions::fromArray([
    'authority' => 'https://<the address Anis gave you>',
    'signatureLifetimeSeconds' => 60,
    'acceptLanguage' => 'Arabic',
    'signingKeyCacheSeconds' => 300,
]);
$signer = PemP256Signer::fromPemFile('/secure/partner-key.pem')->forKey($keyId);
$client = AnisPartnersClient::create($options, $signer);

The settings are checked when you create the client: an address that is not HTTPS (plain HTTP is accepted only on your own machine), a signature lifetime outside 1 to 60 seconds, or a key cache longer than 5 minutes stops it there.

The signer is a separate step on purpose. PemP256Signer signs with a key file that only your application user can read. For a vault or hardware module, implement the P256Signer interface — a method that signs bytes and returns the 64-byte signature — and the key never enters your PHP process.

PHP-FPM and other multi-process hosts: each worker has its own memory, so pass a shared PSR-16 cache and the workers reuse Anis’s public signing-key document instead of fetching it on every new process. Use a cache only your application can write. The SDK keeps Anis’s key list for at most 5 minutes, counted from when it was fetched, even when it comes from the shared cache.

$client = AnisPartnersClient::create($options, $signer, keyCache: $psr16Cache);

Using it with Laravel

There is no separate Laravel package: the SDK works in Laravel as it is. Register one shared client in a service provider (php artisan make:provider AnisPartnersServiceProvider registers it for you), with Laravel’s cache as the shared key cache and Laravel’s logger:

$this->app->singleton(AnisPartnersClient::class, function (Application $app): AnisPartnersClient {
    $config = $app['config']['anis-partners'];               // config/anis-partners.php, read from .env

    return AnisPartnersClient::create(
        new ClientOptions($config['authority'], acceptLanguage: AcceptLanguage::from($config['accept_language'])),
        PemP256Signer::fromPemFile($config['key_file'])->forKey($config['key_id']),
        http: new GuzzleClient(['timeout' => 30, 'connect_timeout' => 5, 'allow_redirects' => false]),
        keyCache: $app['cache']->store($config['key_cache_store']),   // every worker shares Anis's public keys
        logger: $app->make(LoggerInterface::class),
    );
});

Then ask for AnisPartnersClient in any controller, job or command. Use a cache store every worker shares and only your application can write — never array. Place orders from a queued job with Laravel’s own retries off, and recover with resume() under the same id. The complete guide, with the config file and the queued-job rules, is Using the SDK with Laravel.

Use it

$profile = $client->profile()->get();

foreach ($client->wallets()->list() as $wallet) {            // every page, followed for you
    printf("%s %s\n", $wallet->name ?? $wallet->id, $wallet->balance?->amount());
}

foreach ($client->catalogue()->listCards($walletId, $subcategoryId) as $card) {
    printf("%s %s\n", $card->id, $card->unitPrice?->amount());
}

$credential = $client->ownedCards()->reveal($walletId, $soldCardId);

Amounts are Money values with exactly three decimal places — never floats. Money::of('10.500', 'LYD') and $price->multiply(2) do exact arithmetic.

What it verifies

SDK 2.0.1 and later verify every answer of the calls Anis signs — place an order, read an order, reveal a card, reveal an invoice’s cards, the four enrolment calls, application suspension, key revocation and the signature self-check — success and every refusal. It requires the signature there: an answer on one of these calls without a valid signature is thrown away (UnverifiableResponseException) and, on an order, comes back as OrderOutcomeUnknown. It does not verify the information calls — your profile, wallets, the catalogue and your owned cards (list and read): their answers carry no signature, and TLS protects them. It still signs every request. See Verifying answers for which answers are signed.

Orders

use Anis\Partners\Models\CreateOrderRequest;
use Anis\Partners\Models\OrderCompleted;
use Anis\Partners\Models\OrderNotPlaced;
use Anis\Partners\Models\OrderOutcomeUnknown;
use Anis\Partners\Models\OrderProcessing;
use Anis\Partners\Models\OrderReplayed;

$price = $card->unitPrice ?? throw new RuntimeException('This card has no price for this wallet.');
$operationId = \Ramsey\Uuid\Uuid::uuid4()->toString();   // yours — any UUID v4 generator works
$request = new CreateOrderRequest($card->id, 2, $price, $price->multiply(2));   // exact, three decimals
saveOrderIntent($operationId, $walletId, $request);       // store it BEFORE sending

$outcome = $client->orders()->create($walletId, $operationId, $request);

if ($outcome instanceof OrderCompleted) {
    storeCredentials($outcome->credentials);              // store FIRST, whatever happens next
}

match (true) {
    $outcome instanceof OrderCompleted && $outcome->codesWithheld
        => reportCodesWithheld($operationId),             // paid, codes withheld: do not buy again; write to support@anis.ly
    $outcome instanceof OrderCompleted      => markReady($operationId),
    $outcome instanceof OrderReplayed       => ensureStored($outcome->order),        // sent earlier: reveal by invoice if you never stored them
    $outcome instanceof OrderProcessing     => resumeLater($operationId, $outcome->retryAfterSeconds),
    $outcome instanceof OrderOutcomeUnknown => resumeLater($operationId, $outcome->suggestedDelaySeconds),
    $outcome instanceof OrderNotPlaced      => closeNotPlaced($operationId, $outcome->refusal),   // nothing bought: fix the cause, new order, new id
    default => throw new LogicException('Unexpected order outcome.'),
};

$client->orders()->resume($walletId, $operationId, $request) sends the same order again under the same id — the way to recover — and returns the same five outcomes. Recovery is always this signed resend, never a loop of orders()->get(), which only reads state. An order whose status is RecoveryExhausted is not failed: keep resuming the same id, slowly (minutes, not seconds), and tell support@anis.ly the operation id. See Orders and recovery for the full rules.

No retry layer around the HTTP client

A retry middleware on your PSR-18 client resends the same signed request with the same one-time value, and Anis refuses the copy. Recover orders with resume(), which signs a fresh request.

Refusals

An order does not throw a refusal: it comes back as OrderNotPlaced or OrderOutcomeUnknown. Other calls throw an AnisApiException (or a subclass such as PriceChangedException or InsufficientBalanceException) carrying the code, the request id, retryAfter and whether it was replayed. Each error page names the code.

Exception Means On an order
UnverifiableResponseException An answer on a signed call could not be shown to come from Anis; it was thrown away Not thrown — OrderOutcomeUnknown: resume with the same id
LimitExceededException (spending_limit_exceeded, 429) The order would pass the application’s or wallet’s spending cap for the current window; nothing was bought or charged Not thrown — OrderNotPlaced. After retryAfter seconds, place a new order with a new id; never resume this one
RequestSigningException Your signer failed; nothing was sent Thrown — send it again once the signer works
InvalidArgumentException Your input was wrong (an id that is not a UUID, a total that is not unit price × quantity); nothing was sent Thrown before anything is sent

Every SDK failure implements Anis\Partners\AnisPartnersException, so one catch covers them all.

Observability

Logs through your PSR-3 logger; traces and metrics through OpenTelemetry under anis-ly/partners. No key, signature, one-time value or card code is ever recorded. See Observability for what to alert on.

Methods and card answers

Owned-card calls use /v1/wallets/{walletId}/owned-cards, its /{soldCardId} read and /{soldCardId}/reveal, and /v1/wallets/{walletId}/invoices/{invoiceId}/owned-cards/reveal. Catalogue card reads use GET /v1/wallets/{walletId}/catalog/cards/{cardId}.

CardSummary is shared by owned cards, reveals and order soldCards: card id and localized name, optional face value and logo, subcategory and category. Owned cards use soldCardId and operationId. Revealed credentials inside orders include invoice id, card summary and purchase time too. Empty strings and empty localized text are omitted. Money is a string with three decimals and an uppercase three-letter currency — negative only for a wallet balance while the wallet uses allowed debt; read a wallet’s currency from balance.currency.

The SDK requires the v2 signing tag, both request signature headers in the covered list, and the key-document lifecycle checks. See Verifying answers. Stops are immediate; only Anis staff can resume an application, and revoking your current key requires new enrolment. See Security.

$reason = "Suspected compromise";
$catalogueCard = $client->catalogue()->getCard($walletId, $cardId);
$suspended = $client->access()->suspendApplication($reason);
$revoked = $client->access()->revokeKey($keyId, $reason);

Product resources in SDK 2.2.0

SDK 2.2.0 adds meterCharges, directRechargeProducts (products, locations, quote), directRecharges (create, a list of your own, read and recipient updates), webhookEndpoint, webhookEvents, a webhook signature verifier and updated error types. Upgrade from 2.1.0 to use them. Follow the meter, Direct recharge and webhook guides for HTTP behavior. Keep the operation id and exact body before a money-moving call; a lost answer is recovered with the same id. Each SDK’s README and its docs/webhooks.md give the method names and the verifier’s arguments.