Imported from oblodai/oblodai-php (
AGENTS.md). Install upstream withnpx skills add oblodai/oblodai-php. Copyright stays with the author.
Oblodai PHP SDK — guide for coding agents
Package oblodai/sdk (1.3). Everything below is verified against the gateway's contract snapshot
shipped in contract/contract.json.
Non-negotiables
- Amounts are decimal strings:
'amount' => '25', never25or25.0. Do not cast a money field to float; useOblodai\Helper\Money::add()/compare(). - Every method's last argument is
new RequestOptions(idempotencyKey: …, timeoutMs: …, deadlineMs: …, headers: […]). Per-call headers merge over the client's, case-insensitively; nothing the SDK signs can be overridden from there. - One API key.
publicId:/secret:(orOBLODAI_PUBLIC_ID/OBLODAI_SECRET) sign every route the gateway gates — payments, payouts, settings, documents alike.adminToken:is a separate thing entirely: it is a self-hosted gateway's provisioning token, sent only onmerchants->*. - Paged list methods return
Oblodai\Core\Page:->items()/->paginate()is ONE page,foreachwalks every page,->all($max)collects. Nothing is requested until it is consumed. NOT paged, returning a plain array:settings->list/set/deleteAutoWithdraw(),settings->*ApiAllowlist(),payouts->mass()andpayoutLinks->batch()(list<BatchElement>). - Idempotency keys are generated automatically on create routes and reused across retries. Passing
idempotencyKeyto a route the core does not deduplicate throwssdk.idempotency_unsupported. - Request bodies are
array<string, mixed>or a generated DTO fromOblodai\Contract\Request\*(that is where the field documentation lives). Wire field names are snake_case, always.
Naming
| intent | call |
|---|---|
| fetch one | ->info($uuid) or ->info(['order_id' => …]) (alias ->get()) |
| fetch many | ->history($params) on payments/payouts (alias ->list()), ->list($params) elsewhere |
| create | ->create($params); webhooks: ->register($url) |
| many, synchronous | payouts->mass() (≤100), payoutLinks->batch() (≤500) — list<BatchElement> with per-element outcomes |
| many, async | payments->batch(), payouts->batch(), refunds->batch(), transfers->batch() — ≤5000, poll batches->info() |
| documents | documents->*Report() / statement() / feeSchedule() / balanceCertificate() → FileResult |
| provisioning | merchants->create(), merchants->createSandbox($id) — no HMAC; adminToken: on self-hosted gateways |
| payer-facing | payments->publicView/select/publicQr, paymentLinks->publicView/checkout, payoutLinks->claimPreview/claim |
Errors
catch (OblodaiException $err) → $err->errorCode (family.reason), httpStatus, retryable
(authoritative — the SDK already retried what it should), retryAfter, requestId (quote to
support), field (400s), synthetic (the answer came from a proxy, not the API).
Subclasses: ValidationException 400, AuthenticationException 401, PermissionException 403,
NotFoundException 404, ConflictException/IdempotencyConflictException 409,
RateLimitException 429, UnavailableException 503, InternalException other 5xx,
TransportException (no response), ConfigException (before sending), ContractException
(unreadable envelope), SignatureException (webhooks). json_encode($err) keeps the message and
drops the raw body.
Codes worth handling: payout.insufficient_funds (retryable), payout.funds_maturing (retryable),
idempotency.key_reused, invoice.not_payable, payment.not_found,
merchant.bad_signature, request.rate_limited. Full list (469):
Oblodai\Contract\Enums::ERROR_CODES. Each money-moving method's docblock names the codes to
branch on.
The SDK's own codes never come from the API: sdk.missing_credentials, sdk.bad_config,
sdk.bad_header, sdk.bad_path_param, sdk.bad_amount, sdk.bad_idempotency_key,
sdk.idempotency_unsupported (ConfigException); sdk.bad_envelope, webhook.bad_payload
(ContractException); sdk.response_too_large, transport.* (TransportException).
$err->retryAfter is what the gateway asked for, clamped to 24 h; the SDK's own sleep is capped by
Retry::$maxRetryAfterMs.
Statuses
- Payment:
select → created → confirm_check → paid | paid_over | wrong_amount | expired | cancelled.Status::isPaymentPaid()= paid/paid_over.wrong_amountneedsrefunds->resolve(['uuid' => …, 'action' => …]). - Payout:
pending → approved → awaiting_cosign → broadcasting → sent → confirmed | failed | cancelled. - Webhook event types:
invoice.<status>,payout.<status>,wallet.paid; the body'stypeispayment|payout|walletand decodes intoPaymentEvent,PayoutEventorWalletEvent. - Closed vocabularies decode into
Contract\Model\OpenEnum:->valueis the raw wire string,->knownthe typed case or null,->is($caseOrString)compares. A value outside this snapshot NEVER throws — a new gateway status must not turn a webhook into a 500.Wire::strict()makes drift loud in tests. Open vocabularies (network,kind,fee_type,source,event_type) stay strings. Every model keeps the raw wire body in->raw.
Webhooks
use Oblodai\Webhook\Verifier;
$delivery = Verifier::verify(file_get_contents('php://input'), getallheaders(), $secret);
Verify over the raw bytes. An empty secret, an empty previousSecret or a negative tolerance is
a ConfigException before any crypto; toleranceSec: 0 disables the freshness window; the MAC is
compared BEFORE the timestamp. $delivery->isTest (and Verifier::isTestEvent($event)) is true for
rehearsal deliveries (test: true in the signed body) — never treat them as money. Deduplicate on
$delivery->id (X-Webhook-Id); drop out-of-order events with
Verifier::isStale($event, $lastSequence) (false when the event has no sequence). During a rotation
pass previousSecret: for ≥26 h.
Failure shapes a receiver must distinguish: ConfigException → your misconfiguration, not a 401;
SignatureException → answer 401; WebhookPayloadException (webhook.bad_payload, contract
family) → the MAC verified but the body is unreadable, so answer 2xx and alert. An unmodelled event
type is not a failure: it decodes to UnknownEvent, and Verifier::isKnownEvent() says so.
Machine-readable surface
Oblodai\Contract\Routes::SPECS (107 routes: path, auth ∈ {public, key, onboard},
idempotent, safe, bare, list — safe is the core's own hand-classification of read-only routes,
never inferred here),
Oblodai\Contract\Routes::NUMBER_FIELDS (the only request fields that may be a float),
Oblodai\Contract\Request\* (typed bodies per route), Oblodai\Contract\Enums (statuses, networks,
event types, 469 error codes), Oblodai\Contract\Enum\* (the same as PHP enums), and contract/
itself (schemas, golden response bodies per route, error samples, signed webhook samples).