Search by

uengage.io / php-platform-sdk

uengage.io

PHP SDK for the uEngage platform API (zones, business, audit, auth, events, wallet, upsell, alerts). 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: 531

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v6.2.0 2026-11-05 06:12 UTC

This package is not auto-updated.

Last update: 2026-11-05 10:45:49 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']);
// Groups: profile, branding, locale (public); compliance, address, contact, hours, slots,
// order_types, delivery, charges, attributes, app_config, payment_gateway, bank (need grants).

// 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,
// WHATSAPP_DIGITAL_BILL, SMS_FEEDBACK, WHATSAPP_FEEDBACK, SMS_DIGITAL_BILL).
$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
getToken callable renews a static vault token (see Vault); not with serviceId
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)
alertsQueueUrl string alerts SQS FIFO queue for alerts->raise()
env uat | prod picks the default alerts queue
sqsSender SqsSenderInterface default: AwsSqsSender (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, PLATFORM_ALERTS_QUEUE_URL, UENGAGE_ENV.

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 or raise alerts 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.

Alerts client

Raise operational alerts (low wallet balance, dead payment-gateway credentials, ...) and read / resolve them from a dashboard backend. Two transports:

Call Transport Needs
raise() SQS FIFO queue alerts-{env}.fifo host AWS identity with sqs:SendMessage on it, aws/aws-sdk-php
list(), get() GET /v1/alerts[/{id}] Bearer token with alerts:read
setStatus() POST /v1/alerts/{id}/status Bearer token with alerts:write

Raising

$messageId = $platform->alerts->raise([
    'type'        => 'wallet.low_balance',   // category is derived, never sent
    'title'       => 'Wallet balance low',
    'description' => 'Balance fell below the configured threshold.',
    'tenant'      => ['parentId' => 7175, 'businessId' => 38112], // int or string
    'notify'      => false,                   // optional, default false
    'context'     => ['balanceMinor' => 12050, 'currency' => 'INR'], // optional, flat
]);

raise() sends the alert straight to the alerts SQS FIFO queue — no SNS, no event bus, no HTTP call — and otherwise behaves like the events client above:

  • Invalid input throws InvalidArgumentException immediately — unknown type, blank or over-long title (200) / description (4000, both measured after trimming), missing tenant.parentId / tenant.businessId, a non-bool notify, or a context that is nested, list-shaped, over 50 keys, has a key outside 1–64 characters or a string value over 1024 characters.
  • Valid alerts are buffered in the alerts client's own queue and sent at request end (or on $platform->alerts->flush()) in SendMessageBatch calls of up to 10, with short timeouts. The shutdown flush closes the response first (fastcgi_finish_request()) and is capped at 5 batches, like events.
  • Sending fails open: an SQS outage, no resolvable queue, a missing AWS SDK or a denied sqs:SendMessage goes to error_log() and $platform->alerts->failedCount() (cumulative — diff it around a flush()), never to the caller. CLI workers and queue consumers should call $platform->alerts->flush() themselves.
  • The SDK derives category from the type registry (AlertsClient::ALERT_TYPES), mints id (ULID, returned by raise()), stamps occurredAt (UTC, no milliseconds) and stringifies tenant ids so 7175 and "7175" are the same alert. Unknown keys are dropped.
  • Each message goes out with MessageGroupId = {parentId}#{businessId}#{type} (AlertsClient::messageGroupId()), so one alert's messages are processed strictly in order, and MessageDeduplicationId = the message id.

Queue URL, first match wins (same order as the TS SDK):

  1. the alertsQueueUrl option, else PLATFORM_ALERTS_QUEUE_URL;
  2. the default for the env option, else UENGAGE_ENV (AlertsClient::ALERT_QUEUE_URLS):
    • uat: https://sqs.ap-south-1.amazonaws.com/571896969292/alerts-uat.fifo
    • prod: https://sqs.ap-south-1.amazonaws.com/822426641710/alerts-prod.fifo
  3. otherwise (no env, or an unknown one such as dev) there is no queue and every raised alert is logged and counted as failed at flush.

The AWS region comes from the queue URL host (sqs.<region>.amazonaws.com), else AWS_REGION. Credentials come from the AWS SDK's default chain. A queue URL on a non-AWS host (LocalStack, ElasticMQ) is also used as the SQS endpoint.

IAM. The host's AWS identity needs sqs:SendMessage on arn:aws:sqs:ap-south-1:<account>:alerts-{env}.fifo (SendMessageBatch is authorised by sqs:SendMessage). The queue lives in the platform account, so a producer in another AWS account (e.g. the legacy stack) also has to be listed in the alerts queue's resource policy — the platform grants it to the roles in its producer role list; ask for your role to be added. The SDK auth modes are not used for raising. Hosts that own a configured SqsClient can pass 'sqsSender' => new AwsSqsSender(null, $existingClient), or any SqsSenderInterface.

Matching. The service keys an alert on (businessId, parentId, type). While an alert with that identity is open or muted, a new message adds to its count, moves lastSeenAt and updatedAt, and the latest title, description, context and notify win. Once it is resolved, the next raise() starts a new alert row (fresh id, count 1). Title, description and context therefore do not split alerts — put volatile values in context freely.

Listing and resolving

$page = $platform->alerts->list([
    'businessId' => 38112,      // at least one of businessId, parentId, type, category, status, userId, notify => true
    'status'     => 'open',     // open | resolved | muted
    'limit'      => 25,         // 1-100
]);
foreach ($page['items'] as $alert) { /* ... */ }
$next = $page['nextCursor'];   // pass back as 'cursor'; null on the last page

// Every alert of one identity, newest lastSeenAt first: current and resolved ones.
$history = $platform->alerts->list(['businessId' => 38112, 'type' => 'wallet.low_balance']);

// Alerts whose latest message had notify on (false = no filter).
$bell = $platform->alerts->list(['notify' => true, 'status' => 'open']);

// Only alerts this dashboard user can see (their id is in the alert's userIds).
// Counts as a filter on its own and combines with the others. Trusted like
// `actor`: your backend decides which user it is acting for.
$mine = $platform->alerts->list(['userId' => $dashboardUser->id, 'status' => 'open']);

$alert = $platform->alerts->get($alertId);

$platform->alerts->setStatus($alertId, [
    'status'  => 'resolved',        // open | resolved | muted
    'comment' => 'Wallet topped up', // required for resolved and muted
    'actor'   => $dashboardUser->email,
]);

An alert is {id, parentId, businessId, title, description, type, category, context, notify, status, count, firstSeenAt, lastSeenAt, updatedAt, userIds} plus resolvedAt/resolvedBy/resolutionComment, mutedAt/mutedBy/muteComment and reopenedAt/reopenedBy/reopenComment when set. count is the number of messages received while it was open or muted; firstSeenAt / lastSeenAt are the occurredAt of its first and latest message; updatedAt is the last change of any kind. id ({businessId}.{type}#{ulid}) is stable.

userIds lists the dashboard users who can see the alert. The service decides it once, when the alert row starts, from its audience rules: a rule for the alert's business + type if one exists, else one for its parent + type, else a default list. Count bumps never change it, and changing the rules (a service deploy) only affects alert rows started afterwards. A userId list page may hold fewer than limit items while nextCursor is still set — keep paging until it is null.

Status changes: open ↔ muted and open|muted → resolved always work. Reopening (resolved → open, comment optional) works only while no other open or muted alert of the same identity exists — if a later raise() already started a new one, the service answers 409 newer_alert_open with that alert's id:

try {
    $platform->alerts->setStatus($alertId, ['status' => 'open', 'actor' => $user]);
} catch (\Uengage\PlatformSdk\Alerts\AlertsApiException $e) {
    if ($e->getErrorCode() === \Uengage\PlatformSdk\Alerts\AlertsClient::ERROR_NEWER_ALERT_OPEN) {
        $current = $e->getNewerAlertId(); // act on this one instead
    }
}

These use the normal auth modes; the service's token needs alerts:read for list()/get() and alerts:write for setStatus(). Non-2xx responses throw Uengage\PlatformSdk\Alerts\AlertsApiException (getStatus(), getBody(), getErrorCode()).

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.

Vault (credentials and KYC documents)

$platform->vault stores per-brand payment-gateway / POS credentials and KYC documents encrypted, and hands plaintext back only to tokens that carry a vault.<class>:decrypt scope. Vault accepts only vault tokens, each bound to one tenant: a brand (parentId) or one of its outlets (parentId + businessId). A brand token reaches the brand's items and every outlet's; an outlet token reaches only that outlet.

Backend: client credentials

$platform = Client::create(['serviceId' => '...', 'serviceSecret' => '...']);

$vault = $platform->vault->forTenant(5, 7175);   // brand 5, outlet 7175

The SDK mints a vault token for that tenant (urn:uengage:params:oauth:grant-type:vault-business, every vault scope your client's registry entry allows), caches it in the configured token cache until 60 s before expiry, and re-mints once on a 401. Your client needs vault.tokens:issue plus the vault scopes in the auth registry.

$meta = $vault->credentials()->create([
    'type' => 'pg.razorpay',
    'value' => ['key_id' => 'rzp_live_x', 'key_secret' => $secret],
    'referenceId' => 'razorpay-main',          // optional, unique per tenant
    'tags' => ['env' => 'live'],
]);
$meta['masked'];                                // ['key_secret' => 'rzp_******jiu', ...]

$vault->credentials()->get($meta['id']);
$vault->credentials()->getByReference('razorpay-main', 7175);
$page = $vault->credentials()->list(['class' => 'pg', 'tags' => ['env' => 'live'], 'limit' => 50]);
foreach ($vault->credentials()->listAll(['type' => 'pg.razorpay']) as $meta) { /* ... */ }
$vault->credentials()->versions($id);           // masked history
$vault->credentials()->rotate($id, ['key_id' => '...', 'key_secret' => '...']);
$vault->credentials()->setTags($id, ['env' => 'test']);   // replaces the whole set
$vault->credentials()->disable($id);

List filters: type or class (one is required), tags (sent as tag.<key>=<value>), status, businessId, includeOutlets (brand token only), limit (≤ 100), cursor. list() returns one page (['items' => ..., 'nextCursor' => ...]); listAll() is a generator that follows nextCursor.

Decrypt and SecretValue

$cred = $vault->credentials()->decrypt(['id' => $id]);
// or ['referenceId' => 'razorpay-main', 'businessId' => 7175]

$api = new Razorpay\Api($cred['value']['key_id']->reveal(), $cred['value']['key_secret']->reveal());

foreach ($vault->credentials()->decryptAll(['class' => 'pos', 'includeOutlets' => true]) as $cred) {
    // pages of up to 25, followed lazily; no overall cap
}

Every decrypted field is a Uengage\PlatformSdk\Vault\SecretValue. Only ->reveal() returns the plaintext; (string), string interpolation, json_encode, print_r and var_dump all show [REDACTED], the plaintext is not held in an object property (so var_export and (array) casts do not show it), and serialize() / clone are refused. Call reveal() at the point of use and do not store, log or cache the result. Only the active version of an active credential is ever decrypted.

Frontend handoff: mintVaultToken

A backend mints a narrow, short-lived vault token and hands it to a frontend or another service, the same way as mintUpsellToken / mintRealtimeToken:

$token = $platform->auth->mintVaultToken(
    $clientId,
    $clientSecret,
    5,                                        // parentId
    7175,                                     // businessId; null for a brand-wide token
    ['vault.kyc:read', 'vault.kyc:write'],    // null for every allowed vault scope
    900,                                      // expiresIn, 60-14400 s (default 300)
    'user-42'                                 // onBehalfOf, recorded in audit
);
// ['token' => '...', 'expiresIn' => 900, 'parentId' => 5, 'businessId' => 7175,
//  'scope' => 'vault.kyc:read vault.kyc:write']

Never put a vault.<class>:decrypt scope in a token bound for a browser; the dashboard backend's client should not be granted it at all. The client needs vault.tokens:issue; tokens are never cached. Bad ids, non-vault scopes, an out-of-range expiresIn or an invalid onBehalfOf (1-128 printable ASCII characters) throw InvalidArgumentException before sending; a refused mint or a response that does not match the request throws AuthApiException.

Frontend handoff: mintAlertToken

A backend mints a short-lived alerts token for one dashboard user and hands it to that user's frontend. The token only reaches alerts whose userIds include that user (see the alerts audience rules): list is limited to them, and get / setStatus answer 404 for anyone else's. Status changes made with it are recorded as that user, so the frontend sends no actor.

// Your own route, after authenticating the dashboard user
$token = $platform->auth->mintAlertToken(
    $clientId,
    $clientSecret,
    (string) $user->id,                  // userId, 1-64 printable ASCII
    ['alerts:read'],                     // add 'alerts:write' to allow resolve / mute / reopen; null for every allowed one
    900,                                 // expiresIn, 60-14400 s (default 300)
    'user-42'                            // onBehalfOf, recorded in logs (optional)
);
// ['token' => '...', 'expiresIn' => 900, 'userId' => '101', 'scope' => 'alerts:read']

header('Cache-Control: no-store');
echo json_encode(['token' => $token['token'], 'expiresIn' => $token['expiresIn']]);

The frontend uses the TypeScript SDK's browser-safe subpath:

import { createAlertsClient } from '@uengage.io/platform-sdk/alerts/browser';

const alerts = createAlertsClient({
  env: 'prod',
  getToken: async () => (await fetch('/api/alerts-token').then((r) => r.json())).token,
});
const page = await alerts.list({ status: 'open' });

The issuing client needs alerts.tokens:issue plus each alerts:* scope it puts in a token; tokens are never cached. A bad userId, a non-alerts scope, an out-of-range expiresIn or an invalid onBehalfOf throw InvalidArgumentException before sending; a refused mint or a response that does not match the request throws AuthApiException.

Static-token mode

A process that was handed a vault token uses it directly; the tenant comes from the token:

$platform = Client::create([
    'authToken' => $vaultToken,
    'getToken' => function () use ($backend) {          // optional
        return $backend->fetchVaultToken();              // string, or ['accessToken' => ..., 'expiresIn' => ...]
    },
]);
$platform->vault->credentials()->list(['class' => 'pg']);
$platform->vault->forTenant(5, 7175);   // optional: throws ConfigException if the token is for another tenant

getToken is called when the token has expired and once after a 401 (the request is then retried once). Without it, a 401 is final.

Documents (KYC)

$docs = $vault->documents();
$doc = $docs->upload([
    'type' => 'kyc.gst_certificate',
    'contentType' => 'application/pdf',
    'filename' => 'gst.pdf',
    'referenceId' => 'gst:2026',
], file_get_contents($path));                     // DocumentMeta, status 'uploaded'
$view = $docs->view($doc['id'], 'KYC review');    // ['url' => presigned GET (5 min), 'expiresAt' => ...]

upload() is one call: it registers the document (POST /documents, which returns a presigned S3 POST), sends the file straight to S3 (no bearer), then calls POST /documents/{id}/complete, which checks the object's size and content type (409 upload_not_found / upload_mismatch). view() works only for uploaded documents; a pending one answers 409 document_not_uploaded. getByReference, list, listAll and setTags work as for credentials; the list status filter takes pending or uploaded.

Types

$platform->vault->types()->register([
    'type' => 'pg.razorpay',
    'mask' => ['prefix' => 4, 'suffix' => 4, 'minLength' => 12],
    'hiddenFields' => ['webhook_secret'],
]);
$platform->vault->types()->list('pg');

With client credentials, $platform->vault->types() uses the ordinary service token (it needs vault.types:read / vault.types:write); a tenant handle's types() uses its vault token.

Errors

Exception When
VaultDeniedException 403 - every denial is {"error":"forbidden"}; the reason is audited
VaultNotFoundException 404
VaultConflictException 409 - reference_conflict, version_conflict, credential_disabled, document_not_uploaded, upload_not_found, upload_mismatch
VaultValidationException 400 / 422 - bad input, unknown_type
VaultUnavailableException 5xx, or no response at all (status 0)

All extend VaultApiException (getStatus(), getBody(), getErrorCode()), which extends ApiException. Token-mint failures throw AuthenticationException; bad local input (ids, expiresIn, scopes) throws ConfigException before any request.

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/*
Uengage\PlatformSdk\Vault\VaultApiException (+ subclasses) non-2xx from /v1/vault/* (->getErrorCode())
Uengage\PlatformSdk\Alerts\AlertsApiException non-2xx from /v1/alerts/*
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, /v1/alerts/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.