uengage.io / php-platform-sdk
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.
Requires
- php: >=7.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^7.5 || ^9.6
- yoast/phpunit-polyfills: ^3.0
Suggests
- aws/aws-sdk-php: Required only to publish onto the platform event bus ($platform->events). Kept out of require so consumers that never publish are not forced onto the AWS SDK and its PHP >= 7.2.5 floor; EventsClient fails open with an error_log entry when it is absent.
Provides
None
Conflicts
None
Replaces
None
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 viaregister_shutdown_function, inPublishBatchcalls 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 atSHUTDOWN_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 explicitflush()is uncapped: that is the caller choosing to wait. - Fail-open, unconditionally.
flush()catches everything, writes toerror_log(), and returns. A bus outage, an expired instance role, or a missing AWS SDK can never fail an order status change. Callflush()yourself and checkfailedCount()if you want to observe failures. - The SDK stamps
id(monotonic ULID),occurredAt,version,domain, andsource. Call sites supply type, payload, and tenant. publish()does throwInvalidArgumentExceptionfor a malformed type, an unmapped domain, a missingtenantId, or list-shapeddata— 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
itemslist 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
sectionNameandvegon every cart line. Both are optional in the schema and load-bearing in the engine:vegdrives the dietary rule and only engages when every line carries it, andsectionNamedrives the don't-repeat-a-cart-category rule. Omitting them silently disables both. - Size your layout off
meta.maxItems, notdisplay.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.layoutis 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.reportEventsnever throws for I/O. A timeout, DNS failure, expired token or 5xx is logged viaerror_logand 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,$_SESSIONor mysqli is accepted and coerced, matching the server.5.5and'abc'are rejected. Build event and cart-item lists witharray_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 }
balanceis 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 callinggetCreditLine()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:readon top ofwallet.balance:read. A token with only the latter gets a 403 from the second call — not a fallback toprepaid, which would render the frozenwallet_balancethese merchants no longer spend from. listCreditEvents()rows arelimit_changed,settledorsettlement_reversed; a reversal row carriescycleand both settlement kinds carryreleasedNetMinor/releasedGstMinor.- Not a guaranteed 200:
wallet_not_found(404) andunresolvable_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 is400 credit_breakup_requiredrather than a guessed rate.allowNegativeis 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' => falseis refused while the merchant owes —409 credit_line_has_outstanding; readconsumedMinorfromcreditFigures()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_balanceis the fiction the line replaces and is frozen, so the write would move nothing while answering 201. Credit comes back throughsettleInvoice. A genuine refund is accepted — sendreversalOforisRefundand it releases what the original charge consumed. getCreditUtilisationhangs 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:
- APCu (
Uengage\PlatformSdk\Token\ApcuTokenCache) - if ext-apcu is loaded and enabled. Shared across PHP-FPM workers on the host; recommended for production. - File (
Uengage\PlatformSdk\Token\FileTokenCache) - falls back tosys_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.