Search by

uengage.io / php-platform-sdk

uengage.io

PHP SDK for the uEngage platform API (zones, business, audit, auth, events, wallet, upsell). OAuth2 client_credentials, legacy session exchange, and static-Bearer auth modes with pluggable token caching (APCu / file / in-memory), plus a buffered fail-open publisher for the platform event bus.

Package info

github.com/uengage-io/php-platform-sdk

pkg:composer/uengage.io/php-platform-sdk

Statistics

Installs: 515

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v3.2.0 2026-09-17 13:05 UTC

This package is not auto-updated.

Last update: 2026-09-24 10:09:30 UTC


README

Backend-issued public/private suggestion tokens and outlet, brand or global admin tokens: upsell token guide.

PHP client SDK for the uEngage platform API. Mirrors the JS SDK (@uengage.io/platform-sdk) — same five namespaces (zones, business, audit, auth, wallet), same auth modes, same error envelope.

  • Base URL (default): https://api.platform.uengage.io
  • PHP: 7.1+
  • Deps: ext-curl, ext-json (no Guzzle, no other runtime deps)

Restricted realtime channels

Use $platform->auth->mintRealtimeToken($clientId, $clientSecret, 'delivery-updates', 300) on your backend after authorizing the current user's access to that channel. The client must be registered with realtime.tokens:issue. The result is ['token' => ..., 'expiresIn' => 300, 'channel' => 'delivery-updates'] and can be returned from a non-cacheable same-origin token route. Credentials stay server-side.

Names are case-sensitive identifiers, not paths or wildcards. Tokens permit exactly one name, are valid for 60–14400 seconds (300 by default), and cannot publish or call other platform APIs. Minting does not create a publisher. See the complete integration and authorization guide.

Install

composer require uengage.io/php-platform-sdk

If you have not configured Packagist yet, point at the mirror repo directly in composer.json:

{
  "repositories": [{ "type": "vcs", "url": "https://github.com/uengage-io/php-platform-sdk" }]
}

Quick start

use Uengage\PlatformSdk\Client;

$platform = Client::create([
    'serviceId' => 'edge-zones-admin',
    'serviceSecret' => getenv('EDGE_ZONES_ADMIN_SECRET'),
]);

// Zones - the new spatial primitive
$zone = $platform->zones->create([
    'geometry' => [
        'type' => 'Polygon',
        'coordinates' => [[
            [77.5, 12.9], [77.6, 12.9], [77.6, 13.0], [77.5, 13.0], [77.5, 12.9],
        ]],
    ],
    'tags' => ['type' => 'delivery-area', 'city' => 'BLR'],
]);

$matches = $platform->zones->containing([
    'point' => ['lat' => 12.97, 'lng' => 77.59],
    'tags' => ['type' => 'delivery-area'],
]);

// Business read
$record = $platform->business->get(42, ['profile']);

// Audit (buffered; flushed at shutdown or on demand)
$platform->audit->record([
    'event_type' => 'business.profile_updated',
    'tenant' => ['id' => '42', 'parent_id' => null],
    'actor' => ['type' => 'service', 'id' => 'edge-zones-admin'],
    'resource' => ['type' => 'business', 'id' => '42'],
    'changes' => ['name' => ['before' => 'Old', 'after' => 'New']],
]);
$platform->audit->flush(); // optional; shutdown hook will best-effort flush

// Wallet — getWallet(...) returns a handle bound to one business.
// Needs wallet.balance:read (reads) / wallet.transactions:read|write (writes).
// Service ids: use Uengage\PlatformSdk\Wallet\Services (RECHARGE, SMS_CAMPAIGN,
// WHATSAPP_CAMPAIGN, FLASH_DELIVERY, EMAIL, PRISM_AI, WHATSAPP_ALERT, SMS_ALERT).
$wallet = $platform->wallet->getWallet(['id' => 'business:8841']);

$balance = $wallet->getBalance();           // ['balance'=>float, 'balanceMinor'=>int, 'currency'=>[...], 'source'=>..]
$currency = $wallet->getCurrency();          // ['code'=>'INR', 'symbol'=>'₹']

// getBalance() answers with what the business can still SPEND. For a
// merchant on the credit line that is their available credit, and
// 'source' says so ('wallet_balance' | 'credit_line') — so a caller that
// only pre-checks a balance before allowing a charge needs no change.

// On a dashboard, START HERE — wallet balance or credit line?
$o = $wallet->getOverview();   // ['mode' => 'prepaid'|'credit', 'balance' => [...], 'creditLine' => [...]]
// See "Wallet + credit line" below for the rest of the surface.

$txn = $wallet->credit([                     // or ->debit([...])
    'referenceId' => 'order-12345',          // idempotency key
    'amountMinor' => 1180,                    // ₹11.80, in integer minor units of the wallet currency
    'service'     => Services::RECHARGE,      // legacy service_id; see Wallet\Services
    'description' => 'wallet top-up',
    'tags'        => ['source' => 'edge'],
    // 'reversalOf' => '<debit id>',           // on credit → a refund capped by that debit
    // 'isRefund'   => true,                   // on credit → refund:1, uncapped, no link
    // 'paymentId'  => '',                     // on credit; '' is stored verbatim
    // 'allowNegative' => true,                // on debit → permit overdraw
    // 'rto'        => false,                  // on debit
    // Legacy-ledger passthroughs, written as TOP-LEVEL ledger fields:
    // 'taskId' => 'TASK-9912',                // → task_id + transaction_order_id
    // 'units' => 1, 'serviceBaseCost' => 38.5, // serviceBaseCost is MAJOR units
    // 'updatedBy' => 'PetPooja',              // → updated_by; `actor` still records the client
    // 'occurredAt' => '2026-07-01 10:15:00',  // IST; dates the CHARGE, not the write
    //                                         // (backfills/replays) — ≤90 days back, never future
    // 'wallet' => ['parentBusinessId' => '100', 'childBusinessId' => '500'],
    //                                         // names the wallet outright, bypassing routing;
    //                                         // parentBusinessId is asserted, not filtered on
    //                                         // (409 wallet_identity_mismatch if it disagrees)
]);

$page = $wallet->listTransactions(['type' => 'debit', 'limit' => 20]); // keyset-paginated
$one  = $wallet->getTransaction($txn['id']);

Configuration

Client::create([...]) takes the same options as the JS SDK:

Option Type Default
baseUrl string https://api.platform.uengage.io
authBaseUrl string {baseUrl}/auth/business
customerAuthBaseUrl string {baseUrl}/auth/customer
serviceId + serviceSecret string OAuth2 client_credentials mode
authToken string static Bearer mode (caller owns freshness)
session ['id' => ..., 'token' => ...] legacy uEngage session-exchange mode
scope string (optional) space-separated scope list for client_credentials
actorVia string stamped into audit actor.via
cache TokenCacheInterface APCu if loaded, else file-on-disk
http HttpClient default (cURL backend)
eventsTopicArn string SNS topic ARN for the platform event bus
eventsRegion string defaults to the region inside eventsTopicArn
eventSource string envelope source field, default legacy-php
snsPublisher SnsPublisherInterface default: AwsSnsPublisher (aws/aws-sdk-php)

Auth modes are mutually exclusive. Picking more than one throws ConfigException. Picking zero is allowed - the client only works against public endpoints (the openapi spec).

Env defaults (read by Client::create() when an option is omitted): UENGAGE_BASE_URL, UENGAGE_AUTH_BASE_URL, UENGAGE_CUSTOMER_AUTH_BASE_URL, UENGAGE_SERVICE_ID, UENGAGE_SERVICE_SECRET, UENGAGE_SCOPE, UENGAGE_AUTH_TOKEN, UENGAGE_SESSION_ID, UENGAGE_SESSION_TOKEN, UENGAGE_ACTOR_VIA, PLATFORM_EVENTS_TOPIC_ARN, PLATFORM_EVENTS_REGION, PLATFORM_EVENTS_SOURCE.

Events client (platform event bus)

Publishes order-lifecycle events onto the platform's SNS bus. This is the one namespace that does not go through the platform HTTP API — it publishes straight to SNS with the host's AWS credentials (on legacy, the EC2 instance role), so there is no token to mint and no extra hop on the order path.

$platform->events->publish('order.status_changed', [
    'orderId' => $order->id,
    'orderType' => $order->type,
    'status' => $order->status,
    'deliveryStatus' => $order->deliveryStatus,
    'statusRank' => $rank,
], ['tenantId' => $order->businessId]);
  • publish() buffers in memory and returns immediately. The queue is flushed at request end via register_shutdown_function, in PublishBatch calls of up to 10, with short timeouts.
  • The shutdown flush stays off the request's clock. It calls fastcgi_finish_request() first where the SAPI has it (PHP-FPM), so the response is already closed before anything talks to SNS, and it is capped at SHUTDOWN_MAX_CHUNKS (5 batches = 50 events) even then — because closing the client still leaves an FPM worker held, and workers are the scarce resource under load. Against a dead bus the worst case is ~15s of worker time, not minutes. Events past the cap are counted and logged, not retried. An explicit flush() is uncapped: that is the caller choosing to wait.
  • Fail-open, unconditionally. flush() catches everything, writes to error_log(), and returns. A bus outage, an expired instance role, or a missing AWS SDK can never fail an order status change. Call flush() yourself and check failedCount() if you want to observe failures.
  • The SDK stamps id (monotonic ULID), occurredAt, version, domain, and source. Call sites supply type, payload, and tenant.
  • publish() does throw InvalidArgumentException for a malformed type, an unmapped domain, a missing tenantId, or list-shaped data — those are call-site bugs, not runtime conditions, and failing open on them would just fill a DLQ.
  • Each envelope is JSON-encoded individually before the batch is assembled. Malformed UTF-8 (realistic: customer names come straight out of the legacy DB) is substituted on PHP 7.2+ and, failing that, drops just that one entry with a log — its nine batch siblings still go out. Envelopes over the SNS 256 KB message limit are dropped the same way.

Requires aws/aws-sdk-php

It is a suggested, not a required, dependency: this SDK is installed by consumers that only use zones/business/audit, and a hard requirement would drag the AWS SDK — and its PHP >= 7.2.5 floor — into all of them. Applications that publish events add it themselves:

composer require aws/aws-sdk-php

Without it, publish() still buffers and flush() fails open with an error_log() entry. Hosts that already own a configured SnsClient can pass 'snsPublisher' => new AwsSnsPublisher($region, $existingClient), or any SnsPublisherInterface.

Upsell client

Cart-aware upsell suggestions. Three calls carry a storefront integration: ask which slots exist, ask what goes in one, report what happened.

// 1. Which slots does this screen have? Once per screen, cacheable.
$placements = $platform->upsell->placements([
    'channel'    => 'whitelabel_web',
    'parentId'   => 5,   // the brand
    'businessId' => 6,   // the outlet being ordered from
]);

// 2. What goes in one? Re-ask whenever the cart changes.
$result = $platform->upsell->suggest([
    'channel'   => 'whitelabel_web',
    'placement' => 'cart_recommendation',
    'tenant'    => ['parentId' => 5, 'businessId' => 6],
    'context'   => [
        'cart' => [
            'subtotal' => 500,
            'items'    => [
                [
                    'itemId'      => 101,
                    'qty'         => 1,
                    'unitPrice'   => 400,
                    'itemSlug'    => 'margherita',
                    'sectionName' => 'Pizzas',  // drives category no-repeat
                    'veg'         => 1,         // drives the dietary rule
                ],
            ],
        ],
        'fulfilmentMode' => 'delivery',
        'sessionId'      => session_id(),
    ],
]);

if ($result['items'] === []) {
    return '';  // a normal answer — render nothing at all
}

// 3. Report what happened. Fails open — no try/catch needed: a timeout,
//    an expired token or a 500 is logged and returns ['accepted' => 0].
$platform->upsell->reportEvents([
    'channel'       => 'whitelabel_web',
    'placement'     => 'cart_recommendation',
    'tenant'        => ['parentId' => 5, 'businessId' => 6],
    'sessionId'     => session_id(),
    'configVersion' => $result['meta']['configVersion'],
    'events'        => [
        ['type' => 'shown', 'itemId' => 48346322, 'reasonCode' => 'popular_now'],
        ['type' => 'added', 'itemId' => 48346322],
    ],
]);

Six things that are not obvious from the types:

  • An empty items list is a normal outcome, not an error. The price band is a percentage of the cart subtotal, so a small cart legitimately has nothing to suggest. Render nothing — no card, no heading, no empty state.
  • Send sectionName and veg on every cart line. Both are optional in the schema and load-bearing in the engine: veg drives the dietary rule and only engages when every line carries it, and sectionName drives the don't-repeat-a-cart-category rule. Omitting them silently disables both.
  • Size your layout off meta.maxItems, not display.maxItems. The first is what the merchant asked for; the second is what the touchpoint policy allowed. A slot configured for 6 reports 3 at the cart and 2 at checkout.
  • display.layout is opaque — the platform never interprets it. Map it to your own component with a fallback, so a merchant picking a new layout does not need a release.
  • reportEvents never throws for I/O. A timeout, DNS failure, expired token or 5xx is logged via error_log and returns ['accepted' => 0], so telemetry cannot take down a cart render. Caller mistakes — a missing tenant, an empty batch, more than 50 events — still throw, because those are bugs to fix rather than conditions to survive.
  • Ids may be numeric strings. '5' from $_GET, $_SESSION or mysqli is accepted and coerced, matching the server. 5.5 and 'abc' are rejected. Build event and cart-item lists with array_values() if you filtered them — the client re-indexes for you, but a gapped array would otherwise encode as a JSON object and the server rejects that.

Admin plane

Placement configuration. These require a service-actor token carrying upsell.placements:read / :write; a user token gets a 403.

$rows = $platform->upsell->adminListPlacements(['parentId' => 5]);

$platform->upsell->adminUpsertPlacement([
    'parentId'   => 5,
    'channel'    => 'whitelabel_web',
    'key'        => 'cart_recommendation',
    'touchpoint' => 'cart',
    'enabled'    => true,
    'display'    => [
        'title'    => 'Complete your order',
        'maxItems' => 6,
        'layout'   => 'grid_2xn_modal',
    ],
]);

$platform->upsell->adminDeletePlacement([
    'parentId' => 5,
    'channel'  => 'whitelabel_web',
    'key'      => 'cart_recommendation',
]);

adminUpsertPlacement is a full replace, not a patch — channel, key, touchpoint, enabled and display are required on every call, so a partial object is a 400 rather than a merge. Only strategy, strategyParams, display.maxItems and display.layout have server-side defaults. Scope is derived from what you send: no parentId is the platform default, parentId alone is a brand rule, parentId + businessId is one outlet.

Deleting without a parentId targets the platform default, removing the placement for every tenant that has not overridden it, and needs 'confirmDeleteDefault' => true. This client only sends that flag when you pass it truthy, so forgetting parentId gets you a 400 rather than a silent wipe.

Non-2xx responses throw UpsellApiException (getStatus(), getBody()).

Wallet + credit line

getWallet(...) is a lightweight handle — no I/O. The wallet resolves server-side on the first operation.

Which billing model? — start here

Prepaid wallets and merchant credit lines coexist, and which one a merchant is on decides what to render and which calls are legal.

$o = $wallet->getOverview();

if ($o['mode'] === 'credit') {
    $o['creditLine']['limit']['amountMinor'];   // net of GST
    $o['creditLine']['band'];                   // normal|warning|critical|blocked
} else {
    $o['balance']['balanceMinor'];              // prepaid: the wallet balance
}
  • balance is on both arms and means the same thing either way: what the merchant can still spend.
  • One request for a prepaid merchant, two for a credit one, and never a 404 on the happy path — it reads getBalance()['source'] rather than calling getCreditLine() and catching its 404, which would make the normal state of a prepaid merchant an error on every page load.
  • A wallet service older than the credit line (no source) reads as prepaid rather than throwing.
  • The credit arm needs wallet.credit:read on top of wallet.balance:read. A token with only the latter gets a 403 from the second call — not a fallback to prepaid, which would render the frozen wallet_balance these merchants no longer spend from.
  • listCreditEvents() rows are limit_changed, settled or settlement_reversed; a reversal row carries cycle and both settlement kinds carry releasedNetMinor / releasedGstMinor.
  • Not a guaranteed 200: wallet_not_found (404) and unresolvable_wallet (422) both propagate, because both mean there is nothing to render.

Credit line

The ceiling is per merchant, not per wallet, so an outlet's id and its parent's id return the same line.

$line = $wallet->getCreditLine();   // 404 credit_line_not_found if prepaid
$line['consumed'];                  // net, UNPAID — spans months
$line['available'];                 // limit − consumed
$line['estimatedInvoice']['total']; // exact, from recorded GST — not consumed × 1.18

$wallet->setCreditLimit([                              // wallet.credit:write
    'referenceId' => 'admin:limit:1200:2026-09-02',    // idempotency key
    'limitMinor'  => 10000000,                         // ₹1,00,000, NET of GST; 0 blocks
    'onBehalfOf'  => 'user:4471',                      // required — trail names the human
    'reason'      => 'Q3 volume increase',
    // 'enabled' => false,                             // take them off the credit line
]);

$wallet->settleInvoice([                               // on payment IN FULL
    'invoiceRef' => 'invoice:2026-04:88213',           // idempotency key
    'netMinor'   => 500000,                            // the NET figure — what frees credit
    'gstMinor'   => 90000,                             // recorded, not released
    'onBehalfOf' => 'user:9',
]);
$wallet->reverseSettlement(['invoiceRef' => '…', 'onBehalfOf' => 'user:9']);

$wallet->listCreditEvents();                           // limit changes + settlements
$wallet->listCreditEvents(['limit' => 200]);           // the service defaults to 50
$platform->wallet->getCreditUtilisation(['band' => 'warning']);  // cross-merchant
  • Charges on a credit-line merchant must carry breakup — consumption is tracked net of GST, so a missing split is 400 credit_breakup_required rather than a guessed rate. allowNegative is refused (400 credit_override_refused): it disables the balance guard for migration paths, and on a credit line it would disable the limit.
  • The merchant pays gross; settlement releases net. A ₹5,900 invoice on ₹5,000 of consumption frees ₹5,000. Passing gross hands back 18% too much — and it looks right in a test, since both figures sit on the same invoice row.
  • Setting a limit never touches consumption, so raising one hands back exactly the difference. Lowering below what is consumed blocks charges immediately.
  • 'enabled' => false is refused while the merchant owes — 409 credit_line_has_outstanding; read consumedMinor from creditFigures() to say how much to settle. Disabling moves them back to a wallet balance, which with a balance owed abandons it. To stop a merchant trading use 'limitMinor' => 0; to move them off, settle first.
  • Reusing an idempotency key with different figures is 409 credit_idempotency_conflict, not a silent replay. To correct a settled amount, reverse it and settle again rather than inventing a new ref — a new ref leaves the original settlement standing.
  • A plain credit() is refused — 400 credit_top_up_refused. These merchants have no balance to top up: wallet_balance is the fiction the line replaces and is frozen, so the write would move nothing while answering 201. Credit comes back through settleInvoice. A genuine refund is accepted — send reversalOf or isRefund and it releases what the original charge consumed.
  • getCreditUtilisation hangs off $platform->wallet, not a wallet handle: it is the one wallet call not scoped to a business.

Refusals worth branching on

WalletApiException::errorCode() gives the machine-readable code; creditFigures() gives the credit-line figures on credit_limit_reached.

} catch (WalletApiException $e) {
    if ($e->errorCode() === 'credit_limit_reached') {
        $f = $e->creditFigures();
        if ($f['availableMinor'] > 0) {
            // Limit NOT used up — this ONE charge is larger than what is
            // left. A smaller order would go through; do not send them to
            // their account manager.
        }
        // Otherwise the ceiling is reached — possibly from unpaid
        // invoices rather than this month's spend.
    }
}

credit_line_not_found (404) is a prepaid merchant, not a failure — use getOverview() and you never see it.

Money

Every figure is ['amount' => float, 'amountMinor' => int]. Compute in amountMinor (integer paise); amount is for display. On a ledger row amount is gross and breakup.subTotalMinor is what consumed the ceiling — creditMerchantId present means that row's balanceBefore/balanceAfter are available credit, not a wallet balance.

Token caching

By default Client::create() picks the best available cache:

  1. APCu (Uengage\PlatformSdk\Token\ApcuTokenCache) - if ext-apcu is loaded and enabled. Shared across PHP-FPM workers on the host; recommended for production.
  2. File (Uengage\PlatformSdk\Token\FileTokenCache) - falls back to sys_get_temp_dir()/uengage-platform-sdk-php/. Atomic writes, 0600 permissions. Works everywhere.

Plug a custom backend (Redis, Memcached, your app's cache pool) by implementing TokenCacheInterface and passing 'cache' => $yours to Client::create().

For one-off scripts or tests where multi-request reuse doesn't matter, use InMemoryTokenCache.

Error handling

The SDK throws typed exceptions:

Exception When
Uengage\PlatformSdk\Exceptions\ConfigException bad client construction (multiple auth modes, etc)
Uengage\PlatformSdk\Exceptions\AuthenticationException token mint rejected by auth surface
Uengage\PlatformSdk\Zones\ZonesApiException non-2xx from /v1/zones/*
Uengage\PlatformSdk\Wallet\WalletApiException non-2xx from /v1/wallet/* (->errorCode(), ->balanceMinor(), ->creditFigures())
Uengage\PlatformSdk\Business\BusinessApiException non-2xx from /v1/businesses/*
Uengage\PlatformSdk\Audit\AuditApiException non-2xx from /v1/audit/events
Uengage\PlatformSdk\Auth\AuthApiException non-2xx from /auth/business/*
InvalidArgumentException bad local input (non-uuid, out-of-range lat/lng, etc)

All *ApiException types extend ApiException and expose getStatus(): int + getBody(): string.

The SDK transparently rotates the token + retries once on a 401 when the auth mode supports invalidation, so most expiry-related 401s never surface to your code.

Testing the SDK locally

composer install
vendor/bin/phpunit

API surface — full reference

See /v1/zones/openapi.json, /v1/businesses/openapi.json, /v1/audit/openapi.json, /v1/upsell/openapi.json, /auth/business/openapi.json for the wire contracts. The PHP namespace structure mirrors the JS SDK 1:1 — refer to packages/platform-sdk/src/<namespace>/ for the canonical type shapes.