oblodai / sdk
Official PHP SDK for the Oblodai payment gateway: accepting payments, payouts, static wallets, webhooks. Generated from the gateway's OpenAPI contract.
Requires
- php: >=8.2
- ext-curl: *
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.51
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- psr/log: ^1.1 || ^2.0 || ^3.0
Suggests
- psr/http-client-implementation: To use Oblodai\Http\Psr18HttpClient instead of the bundled cURL client
- psr/log: To route the SDK log through Monolog or any PSR-3 logger via Oblodai\Log\Psr3Logger
Provides
None
Conflicts
None
Replaces
None
README
Official PHP SDK for the oblodai payment gateway
Payments, payouts, payment links, splits, static wallets, webhooks — one API key.
Documentation · Dashboard · Read in Russian →
The official PHP SDK for the Oblodai payment gateway: accepting payments, payouts, bulk operations (batches), payment links, payout links (crypto cheques), splits, static wallets, transfers, webhooks. Request signing, response parsing, typed errors, idempotency and retries — out of the box.
PHP ≥ 8.2 with ext-json and ext-curl, PSR-4 and declare(strict_types=1) throughout, a readonly
model for every request and response body, and nothing else at runtime but the PSR HTTP interfaces:
cURL is used out of the box, and any PSR-18 client can take its place. The resources, methods and
models are generated from the gateway's OpenAPI contract, so the SDK always speaks the API it ships
against.
Base URL. Defaults to
https://api.oblodai.com. OverridebaseUrland supply your own keys at initialisation if needed. The scheme must behttps://; plainhttp://(loopback included) is accepted only with the explicit allow-insecure option (allowInsecureBaseUrl: true, orOBLODAI_ALLOW_INSECURE=1), and auser:password@base URL is refused.
Installation
composer require oblodai/sdk
PHP 8.2 or newer with ext-json and ext-curl. Composer pulls in the PSR HTTP interfaces
(psr/http-client, psr/http-factory, psr/http-message); psr/log is optional and only needed
to route the SDK's log through Monolog or another PSR-3 logger. Upgrading from 1.x: see
MIGRATION-2.0.md.
Where to get keys
A merchant has one API key, issued in the dashboard at my.oblodai.com → API keys. It is a public id and a secret; the secret only ever signs a request, it is never sent.
| key | public id | secret | what it opens |
|---|---|---|---|
| live API key | oblodai_<hex> |
oblodai_live_<hex> |
the whole merchant API: money in, money out, settings, documents |
| sandbox key | test_oblodai_<hex> |
oblodai_test_<hex> |
the same API against the sandbox |
| operator routes | — | — | not supported: sandbox->onboardStore() throws sdk.operator_channel_unsupported — use the dashboard |
That one pair signs every route the gateway gates — there is nothing to choose per call:
use Oblodai\Oblodai; $oblodai = new Oblodai(publicId: $publicId, secret: $secret);
Quick start
use Oblodai\Helper\Status; // Credentials fall back to OBLODAI_PUBLIC_ID / OBLODAI_SECRET. $oblodai = new Oblodai(); $invoice = $oblodai->payments->create([ 'amount' => '25', // amounts are decimal strings, never floats 'currency' => 'USDT', // what you price in — a fiat (USD, EUR, …) or a crypto asset 'network' => 'tron', // omit to let the payer choose the network on the pay page 'order_id' => 'order-1001', // your reference; the invoice is idempotent per order_id 'url_callback' => 'https://shop.example/oblodai/webhook', ]); echo $invoice->url, ' ', $invoice->address, ' ', Status::value($invoice->status), "\n"; // "created"
To price in fiat, add to_currency: ['amount' => '25', 'currency' => 'USD', 'to_currency' => 'USDT'] — currency is what you charge, to_currency the asset the payer sends.
Money out is the same shape — the same key signs it — with an idempotency key of your own so a retry after a restart cannot send twice:
use Oblodai\Core\RequestOptions; $payout = $oblodai->payouts->create([ 'amount' => '10', 'currency' => 'USDT', 'network' => 'tron', 'address' => 'TQrY8bkbpXKPt2LZbU8jqfnpFbUSF15sbx', 'order_id' => 'payout-1001', ], new RequestOptions(idempotencyKey: 'payout-1001')); echo $payout->uuid, ' ', Status::value($payout->status), "\n"; // "pending"
A request body is an array (its shape is in the method's docblock, so your editor completes the keys) or the generated request model, whose named arguments complete too:
use Oblodai\Generated\Model\PaymentRequest; $invoice = $oblodai->payments->create(new PaymentRequest( amount: '25', currency: 'USDT', network: 'tron', order_id: 'order-1002', ));
Runnable versions of all of this are in examples/; the test suite runs them, and every
code block of this README, against a mock gateway.
Sandbox / testing
A sandbox key (test_oblodai_…) drives a chainless copy of the gateway: fake balance from a faucet,
simulated deposits, real signed webhooks. Integrate against it first — nothing here touches a chain.
// The buyer pays: repeat the same txid to add confirmations. $oblodai->sandbox->simulateDeposit([ 'invoice_id' => $invoice->uuid, 'amount' => '25', 'confirmations' => 20, 'txid' => 'sandbox-tx-1', ]); $oblodai->sandbox->faucet(['asset' => 'USDT', 'amount' => '100']); // test funds foreach ($oblodai->sandbox->listWebhooks(limit: 10)->items() as $delivery) { echo $delivery->event_type, ' ', Status::value($delivery->status), "\n"; // the webhook inspector $oblodai->sandbox->replayWebhook(['delivery_id' => $delivery->id]); // re-send a finished one } $oblodai->sandbox->reset(); // cancel open invoices, zero the balances
A rehearsal delivery can also be requested against live: webhooks->sendTestPayment(['url_callback' => …, 'status' => 'paid']) sends a sample event signed exactly like a real one, with test: true
in the body. Never let one move money in your system — see Webhooks. A live key
answers 403 sandbox.live_key on the sandbox helpers.
Method overview
Every operation of the gateway's contract, as $oblodai-><namespace>-><method>(), named after its
operationId (the 1.x names are in MIGRATION-2.0.md). The table is written by
the generator:
17 resources, 124 methods.
| Resource | Methods |
|---|---|
payments |
create · getInfo · getQr · listHistory · listServices · cancel · sendEmail · setCheckoutConfig · getCheckoutConfig · getAmlLinks · resolve |
paymentLinks |
create · list · get · toggle |
refunds |
payment · calculate · blockedWallet |
payouts |
create · createMass · getInfo · listHistory · calculate · validate · cancel · approve · listServices · transferToPersonal · transferToUser · createTransferBatch |
payoutLinks |
create · createBatch · list · get · cancel · getPayoutClaim · claimPayout |
batches |
createPayment · createRefund · createPayout · getInfo |
splits |
createRule · listRules · deleteRule · setConfig · getConfig · setRecipientOptIn · getRecipientOptIn |
wallets |
create · block · getQr |
account |
getBalance · getSummary · listExchangeRates |
webhooks |
resendPayment · register · listDeliveries · requeueDelivery · sendLegacyTest · sendTestPayment · sendTestWallet · sendTestPayout · sendTestConversion · rotateSecret · setActive |
settings |
setAccuracy · getAccuracy · setAutoRefund · getAutoRefund · setDiscount · listDiscounts · listApiLog · getAutoConvert · setAutoConvert · setAcceptedCurrencies · listAcceptedCurrencies · setPayoutFeeConfig · getPayoutFeeConfig · setRefundFeeConfig · getRefundFeeConfig · setPaymentFeeConfig · getPaymentFeeConfig · setAutoWithdrawRule · listAutoWithdrawRules · deleteAutoWithdrawRule · configureVrcs |
apiAllowlist |
list · addEntry · removeEntry · setEnabled |
referrals |
getInfo |
documents |
getSigned · getBalance · getFees · getLedger · getSplit · getPayoutLinkCheque · getStatement · getBatch · getPaymentLink · getWalletStatement · getReferrals · createJob · getJob · downloadJobFile |
checkout |
getSourceOfFundsForm · submitSourceOfFunds · getPublicPaymentLink · paymentLink · listCurrencies · get · selectMethod · startOnramp · getOnramp · getQr |
sandbox |
onboardStore · faucet · simulateDeposit · reset · listWebhooks · replayWebhook |
cliLogin |
start · poll · logout |
Every method takes an optional last argument, RequestOptions — see
Retries, idempotency and timeouts. Path parameters come first
($oblodai->checkout->get($uuid)), query parameters are named arguments
($oblodai->documents->getStatement(from: '2026-01-01', to: '2026-01-31', format: 'csv')), and a
body is an array or a request model. Each method's docblock carries the gateway's own description,
its route and the error codes it can answer with.
Lists
The paged list methods return an Oblodai\Core\Page — a lazy handle: nothing is requested until you
consume it. foreach walks every item across every page, byPage() every page, first() (or
items() / paginate()) only the first. Walking stops when the gateway says has_pages: false or
hands back a short page, whichever comes first.
$page = $oblodai->payments->listHistory(['limit' => 50]); $page->items(); // list<PaymentView> — the first page $page->paginate()->total; // total, per_page, offset, has_pages foreach ($oblodai->payouts->listHistory(['status' => 'confirmed']) as $payout) { echo $payout->uuid, "\n"; // walks page after page, lazily } foreach ($oblodai->payouts->listHistory(['limit' => 100])->byPage() as $onePage) { echo count($onePage), ' of ', $onePage->paginate->total, "\n"; // one request per page } $refunds = $oblodai->payouts->listHistory(['kind' => 'refund'])->all(1000);
Long-running operations
Bulk batches (batches->createPayment/createPayout/createRefund, payouts->createTransferBatch)
and document exports (documents->createJob) finish in the background. asJob() wraps the create
call and returns an Oblodai\Core\Job: ->id, the create answer ->result, and ->wait(), which
polls until the status is terminal (completed/stopped for a batch, done/failed/expired for
an export) and returns that last answer — a failed job is returned, not thrown.
use Oblodai\Generated\Resource\Batches; use Oblodai\Generated\Resource\Documents; $job = $oblodai->batches->asJob(fn (Batches $b) => $b->createPayout(['payouts' => [[ 'amount' => '5', 'currency' => 'USDT', 'network' => 'tron', 'address' => 'TQrY8bkbpXKPt2LZbU8jqfnpFbUSF15sbx', 'order_id' => 'bulk-1', ]]])); $info = $job->wait(timeout: 300, interval: 2); // BatchInfoResponse echo $job->id, ' ', Status::value($info->status), "\n"; $export = $oblodai->documents->asJob(fn (Documents $d) => $d->createJob(['kind' => 'statement', 'format' => 'csv'])); $export->wait(); $export->download()->saveTo(sys_get_temp_dir() . '/statement.csv');
Which operations are jobs, and how each is followed, is the contract's (x-sdk-poll), generated
into Oblodai\Generated\Facts and read through Oblodai\Lro::JOBS.
Statuses
- Payment:
select → created → confirm_check → paid | paid_over | wrong_amount | expired | cancelled(andunder_review).Status::isPaymentPaid()is true forpaid/paid_over;wrong_amount(underpaid) waits forpayments->resolve(['uuid' => …, 'action' => 'accept'|'refund']);Status::isPaymentFinal()covers the rest. - Payout:
pending → approved → awaiting_cosign → broadcasting → sent → confirmed | failed | cancelled.
Which statuses are final, and which of those mean success, is the contract's (x-status-classes):
the generated enums carry it (PaymentStatus::FINAL, PaymentStatus::Paid->isSuccess()), and the
Status helpers wrap it.
Prefer webhooks for state changes; poll getInfo() only as a fallback.
A status (and every other closed vocabulary) is the generated enum's case — or, for a value newer than this SDK, the plain wire string. An unfamiliar value never throws: the gateway adds statuses on its own schedule, and a webhook receiver that refused the first unfamiliar one would answer 500 to an authentic delivery and have it redelivered for a day.
use Oblodai\Generated\Enum\PaymentStatus; $payment = $oblodai->payments->getInfo(['uuid' => $invoice->uuid]); $payment->status === PaymentStatus::Paid; // a known value is the case Status::value($payment->status); // "paid" — the wire string, known or not Status::isPaymentPaid($payment->status); // false for a status this SDK does not know $payment->extra; // fields newer than this SDK, exactly as received
Money
Amounts are decimal strings in both directions — the models type them string, and a float
anywhere in a request body (bar the numbers the contract types number — not money,
Oblodai\Generated\Facts::NON_MONEY_NUMBERS) fails before the network with sdk.float_amount. Oblodai\Helper\Money::add(), subtract(), compare(),
equals(), isZero(), isPositive(), assertAmount() do exact decimal arithmetic on those
strings. Never cast a money field to float, and never compare amounts as strings ("9" sorts
after "10" as text and before it as money — use compare()).
Webhooks
Register an endpoint with webhooks->register(['url' => …]) — the signing secret is returned once,
so store it then. Verify every delivery over the raw request bytes (file_get_contents( 'php://input')), with the request headers (getallheaders()); a re-serialised parse will not
match.
use Oblodai\Generated\Model\PaymentWebhook; use Oblodai\Webhook\Verifier; $delivery = Verifier::verify( rawBody: $rawBody, // the RAW bytes, never a re-encoded parse headers: $headers, secret: (string) getenv('OBLODAI_WEBHOOK_SECRET'), ); if ($delivery->isTest) { // a rehearsal (signed body `test: true`) — no money moved http_response_code(200); } else { $event = Verifier::model($delivery->event); // PaymentWebhook | PayoutWebhook | WalletWebhook | ConversionWebhook | null if ($event instanceof PaymentWebhook && Status::isPaymentPaid($event->status)) { markOrderPaid($event->order_id); } http_response_code(200); }
Verifier needs no client and no API key. Answer the right status to the right failure:
| exception | what happened | answer |
|---|---|---|
ConfigException |
your receiver is misconfigured (no secret) | 500, and fix it |
SignatureException |
not our delivery, or too old | 401 |
WebhookPayloadException |
our delivery, unreadable body | 2xx (or 400) + alert |
webhook.bad_payload is deliberately NOT in the signature family: the MAC already proved the event
is authentic, and answering 401 would make the gateway redeliver it for a day. 401 is for a
signature failure and nothing else. An event type this SDK does not model is not a failure
either — $delivery->event keeps the body, Verifier::model() returns null and
Verifier::isKnownEvent() false.
$delivery->eventKey — the signed body's event_id (fallback type:objectId:sequence for a
delivery from an older core without it) — names the STATE a delivery carries: the same for every
retry and resend of it (a resend raises sequence but keeps event_id); keep the ones you handled and skip
repeats. The X-Webhook-Id / X-Webhook-Event-Id / X-Webhook-Event / X-Webhook-Test headers
are not signed — a replay can carry any values there — so they are reported only under
$delivery->unverified and must never decide whether a delivery is processed; $delivery->isTest
comes from the signed body only, and a rehearsal is always acknowledged and ignored.
Verifier::isStale($delivery->event, $lastSequence) drops out-of-order deliveries. After webhooks->rotateSecret() pass previousSecret: for at least
26 hours. An empty secret is a ConfigException, never a verification against HMAC('', body);
the freshness window is 300 seconds (toleranceSec: 0 disables it) and is checked after the MAC,
so it cannot be used to probe your clock. See examples/webhook-receiver.php.
Errors
Every failure is an Oblodai\Exception\OblodaiException whose message reads in a log line —
[payout.insufficient_funds] insufficient balance (request_id=5f0c…) — and which carries the API's
error envelope: errorCode, detail (the text alone), httpStatus, retryable, retryAfter,
requestId, field, synthetic (the answer came from a proxy, not the API). Quote requestId to
support; json_encode($err) keeps the message and the classification and drops the raw body.
| class | HTTP | when |
|---|---|---|
ValidationException |
400 | the request body is wrong (field says where) |
AuthenticationException |
401 | bad signature, unknown key, stale timestamp |
PermissionException |
403 | valid key, but the call is not allowed |
NotFoundException |
404 | no such object |
ConflictException / IdempotencyConflictException |
409 | state conflict; a key reused with another body |
RateLimitException |
429 | throttled — retryAfter says how long |
UnavailableException |
503 | gateway busy or frozen; retryable |
InternalException |
other 5xx | gateway fault |
TransportException |
— | no response at all (timeout, network, deadline) |
ConfigException |
— | rejected before anything was sent |
ContractException |
— | unreadable envelope or webhook body |
SignatureException |
— | webhook verification failed |
retryable is authoritative: the SDK already retried whatever it should have. Branch on
errorCode — family.reason; the codes each method can answer with are listed in its docblock, and
the full catalogue is the generated enum Oblodai\Generated\Enum\ErrorCode.
use Oblodai\Exception\OblodaiException; try { $payout = $oblodai->payouts->create([ 'amount' => '10', 'currency' => 'USDT', 'network' => 'tron', 'address' => 'TQrY8bkbpXKPt2LZbU8jqfnpFbUSF15sbx', 'order_id' => 'payout-1002', ]); } catch (OblodaiException $err) { match ($err->errorCode) { // retryable — the balance may still arrive 'payout.insufficient_funds', 'payout.funds_maturing' => scheduleRetry($err->retryAfter ?? 60), default => throw $err, // the SDK already retried what was safe to retry }; }
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.float_amount,
sdk.bad_idempotency_key, sdk.idempotency_unsupported, sdk.lro_unresolved (all
ConfigException), sdk.bad_envelope and webhook.bad_payload (ContractException),
sdk.response_too_large, transport.timeout, transport.network, transport.deadline
(TransportException).
Retries, idempotency and timeouts
- Safe to repeat is the contract's own answer (
x-retry-safe), not a guess from the path: each generated route carries it (Oblodai\Generated\Routes). - An error is retried only when the API says
retryable: true. Answers without an API envelope (a proxy 502/503) and transport failures are retried only on retry-safe routes or keyed writes — a write the gateway does not deduplicate is never re-sent once it may have arrived. - Idempotency keys are generated automatically on the routes the gateway deduplicates (one per
call, reused on every retry), so a timeout can never produce a second payout. Pass your own key to
stay safe across restarts too. On a route the gateway does not deduplicate the SDK refuses a key
(
sdk.idempotency_unsupported); a key reused with a different body is a 409idempotency.key_reused. - Request ids: every call sends its own
X-Request-ID(the same on every retry of it) — passrequestIdto tie your logs to ours. An error carries it when the gateway's envelope names none. - Per-call options:
new RequestOptions(idempotencyKey: …, timeout: …, maxRetries: …, extraHeaders: […], requestId: …).timeoutis seconds per attempt;extraHeadersmerge over the client's, case-insensitively; nothing the SDK signs can be overridden from there. - Policy:
retry: new Retry(maxRetries: 2, baseDelayMs: 250, maxDelayMs: 4000, maxRetryAfterMs: 30000)— the defaults.timeout(default 30 s) bounds one attempt,deadline(default 90 s) the whole call including pauses.Retry-Afteralways wins over the computed backoff. - Clock skew is corrected once per call: if the gateway rejects the timestamp, the SDK learns
the server's time from the
Dateheader and re-signs, then keeps the offset for later calls. - Redirects are never followed — the signature covers the path that was requested — and the response body is read with a ceiling: 8 MiB on JSON routes, 64 MiB on document routes.
$quick = $oblodai->payments->getInfo( ['uuid' => $invoice->uuid], new RequestOptions(timeout: 5, maxRetries: 0, requestId: 'checkout-7-status'), );
Raw responses, client copies and hooks
use Oblodai\Core\Hooks; use Oblodai\Core\ResponseInfo; use Oblodai\Generated\Resource\Payments; // Status, headers and request id of a successful answer; parse() is what the method returns. $raw = $oblodai->payments->withRawResponse(fn (Payments $p) => $p->getInfo(['uuid' => $invoice->uuid])); echo $raw->status, ' ', $raw->requestId, ' ', $raw->parse()->uuid, "\n"; // A copy with other defaults; the original is untouched, the HTTP client and clock are shared. $patient = $oblodai->withOptions(timeout: 60, maxRetries: 5, extraHeaders: ['X-Shop' => 'eu-1']); // Hooks see every attempt (the signature redacted) — for metrics, tracing, structured logs. $observed = new Oblodai(hooks: new Hooks( onResponse: function (ResponseInfo $r): void { error_log(sprintf('%s %d %.3fs %s', $r->request->operationId, $r->status, $r->elapsed, $r->request->requestId)); }, ));
An error status still throws through withRawResponse(). A hook that throws stops the call.
Configuration
use Oblodai\Core\Retry; $oblodai = new Oblodai( publicId: $publicId, secret: $secret, baseUrl: 'https://api.oblodai.com', timeout: 30, deadline: 90, retry: new Retry(maxRetries: 2), );
| option | default | what it does |
|---|---|---|
publicId / secret |
environment | the API key; the secret only ever signs |
baseUrl |
https://api.oblodai.com |
API origin; a path prefix is kept |
http |
CurlHttpClient |
custom HTTP stack — see Psr18HttpClient |
timeout |
30 |
per-attempt timeout, seconds |
deadline |
90 |
overall budget per call, retries and pauses included, seconds |
retry |
new Retry() |
retry policy; new Retry(maxRetries: 0) turns retries off |
logger |
none | structured logger; OBLODAI_LOG picks a console one |
headers |
[] |
extra headers on every request |
hooks |
none | new Hooks(onRequest: …, onResponse: …) |
adminToken |
— | deprecated and ignored (never sent); a warning is logged |
allowInsecureBaseUrl |
false |
permit a plain-http baseUrl (loopback included) |
clock, env |
real clock, real environment | injectable, for tests |
| variable | what it sets |
|---|---|
OBLODAI_PUBLIC_ID |
the API key's public id |
OBLODAI_SECRET |
the API key's secret |
OBLODAI_ADMIN_TOKEN |
deprecated and ignored (never sent) |
OBLODAI_BASE_URL |
API origin, default https://api.oblodai.com; a path prefix is kept |
OBLODAI_LOG |
debug|info|warn|error — logs to STDERR |
OBLODAI_ALLOW_INSECURE |
1 permits a plain-http baseUrl (loopback included) |
An empty value counts as unset. Explicit constructor arguments always win over the environment, and
env: [] in the constructor ignores it entirely (used by the test suite).
Secrets never reach a log. Credentials keep their value off the object itself, so print_r/var_dump/json_encode/serialize of the client, its config or its transport
show [redacted]. The models that carry a one-time secret (a webhook secret, a payout link's
claim_token, claim_url and passcode) mask it in json_encode, var_dump and serialize
while the property stays readable and toArray() returns it as is — but print_r/var_export
read public properties directly, so do not print_r such a model. Whatever logger you inject is
wrapped, so redaction happens before the SDK hands anything over. Every parameter that carries a
secret is marked #[\SensitiveParameter], so exception traces (and the error trackers that read
them) show SensitiveParameterValue instead. Hooks and error messages see claim tokens in the path
and signed-link sig/exp masked, and every credential header redacted.
HTTP stack
cURL is the default. To reuse your own client, wrap any PSR-18 implementation:
use Oblodai\Http\Psr18HttpClient; $oblodai = new Oblodai( publicId: $publicId, secret: $secret, http: new Psr18HttpClient($client, $requestFactory, $streamFactory), );
PSR-18 describes only "send this request, get a response", so three things must be configured on the
client itself: no redirects (allow_redirects: false in Guzzle, max_redirects: 0 in
Symfony), timeouts (connect and total — the SDK's timeout cannot be applied through PSR-18,
only the overall per-call deadline) and TLS verification left on. CurlHttpClient enforces all
three itself.
Self-hosted or local gateway
A plain-http baseUrl — http://localhost:8093 included — needs allowInsecureBaseUrl: true (or
OBLODAI_ALLOW_INSECURE=1). A path prefix in baseUrl is kept. The operator-only route
sandbox->onboardStore($merchantId) is not supported by the SDK (the core accepts it over the
operator channel only): it throws sdk.operator_channel_unsupported before any request.
Generated code
src/Generated (Oblodai\Generated\… — the resources, the models, the enums, the route table and
Facts: long-running operations, webhook kinds, non-money numbers)
is generated by the gateway's tools/sdkgen from its OpenAPI contract and is never edited by hand;
the hand-written runtime (Oblodai\Core, Oblodai\Exception, Oblodai\Webhook, Oblodai\Http) is
what signs, sends, retries and parses. names.lock pins every public method name: a name that
vanishes or changes fails the generator as a breaking change, and a new one is added to the lock
by the generator, as is the method table above. make drift regenerates into a temporary directory
and fails when src/Generated, names.lock or that table differs.
Development
make ci # vendor, drift, lint, stan (level max), test, package make ci OBLODAI_BACKEND=/path/to/oblodai-backend # the backend checkout (default ../oblodai-backend) make live OBLODAI_LIVE_URL=http://127.0.0.1:8095 # the live tier against a running gateway
Everything runs in docker (php:8.3-cli, php-cs-fixer on php:8.2-cli, composer:2); the drift
check needs Go for the backend's generator. The tests are unit (runtime), contract (generated code, README, examples) and the shared
conformance suite of every Oblodai SDK (tools/sdkgen/conformance of the backend: signing vectors,
retries, idempotency keys, money, forward compatibility). The live tier is skipped unless
OBLODAI_LIVE_URL points at a running gateway; it provisions its own merchant and spends only fake
money.
Writing code with an AI agent? Point it at AGENTS.md. Upgrading from 1.x — see MIGRATION-2.0.md; the release history is in CHANGELOG.md.
License
MIT — see LICENSE.