tungo / sdk
Official PHP SDK for the Tungo Platform API (AI accountant for 1C, automations, webhooks)
Requires
- php: >=8.2
- php-64bit: *
- ext-curl: *
- ext-json: *
- nyholm/psr7: ^1.8
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
Suggests
- guzzlehttp/guzzle: Pass a Guzzle client as httpClient to send requests through Guzzle (streaming included)
Provides
None
Conflicts
None
Replaces
None
README
Tungo SDK for PHP
The official PHP library for the Tungo Platform API
tungo.kz · Quick start · API reference · Changelog · Issues
The official PHP library for the Tungo Platform API (https://api.tungo.kz): the AI accountant, 1C integration, bank statements, e-invoices, tax forms and automations of Tungo AI.
It is generated from the API's OpenAPI contract, so its methods and models always match the contract it was built from.
Requirements
PHP 8.2 or newer (64-bit) with the curl and json extensions. The library depends on the PSR HTTP interfaces (psr/http-client, psr/http-factory, psr/http-message), php-http/discovery and nyholm/psr7; it is tested on PHP 8.2, 8.3, 8.4 and 8.5.
Installation
composer require tungo/sdk
php-http/discovery ships an optional Composer plugin. Composer asks whether to enable it; the SDK works either way, so in a non-interactive install you can answer in advance with composer config allow-plugins.php-http/discovery false.
Quick start
use Tungo\Sdk\TungoClient; // The API key is read from TUNGO_API_KEY; companyId is the default company of company-scoped calls. $client = new TungoClient(companyId: getenv('TUNGO_COMPANY_ID') ?: 'co_01h455vb4pex5vsknk084sn02q'); $turn = $client->ask('Какой НДС к уплате за октябрь?'); echo $turn->answer, PHP_EOL; echo 'dialog ', $turn->dialogId, ', stop reason ', $turn->stopReason ?? 'none', PHP_EOL;
Authentication and configuration
Create one client and share it: it is immutable and safe to use from several threads or tasks. Every option can be passed explicitly; five of them fall back to environment variables.
| Option | Environment variable | Default |
|---|---|---|
| API key | TUNGO_API_KEY |
none |
| Base URL | TUNGO_BASE_URL |
https://api.tungo.kz |
| Webhook secret | TUNGO_WEBHOOK_SECRET |
none |
| Default company | TUNGO_COMPANY_ID |
none |
| Default workspace | TUNGO_WORKSPACE_ID |
none |
| Timeout | 60 s (600 s for non-streaming chat turns) | |
| Maximum retries | 2 | |
| Default headers, HTTP client | none, built-in |
The API key is sent only to operations that require it; public endpoints and incoming hooks never receive it, and it never appears in error messages or debug output. Calling an authenticated operation without a key fails locally, before any request is made.
Every method also accepts per-request options: timeout, maximum retries, extra headers, an idempotency key and cancellation.
use Tungo\Sdk\CancellationToken; use Tungo\Sdk\RequestOptions; use Tungo\Sdk\TungoClient; $client = new TungoClient( apiKey: getenv('TUNGO_API_KEY') ?: null, // default: TUNGO_API_KEY baseUrl: getenv('TUNGO_BASE_URL') ?: null, // default: TUNGO_BASE_URL, else https://api.tungo.kz webhookSecret: getenv('TUNGO_WEBHOOK_SECRET') ?: null, // default: TUNGO_WEBHOOK_SECRET companyId: 'co_01h455vb4pex5vsknk084sn02q', // default company of company-scoped methods workspaceId: 'ws_01h455vb4pex5vsknk084sn02q', // default workspace of workspace-scoped methods timeout: 30.0, // seconds; null means 60 s (600 s for chat turns) maxRetries: 3, defaultHeaders: ['X-App-Name' => 'billing'], // httpClient: $psr18Client, // any PSR-18 client; a Guzzle client also streams ); // Per-request options are the last argument of every method. $response = $client->companies->listWithResponse(options: new RequestOptions( timeout: 10.0, maxRetries: 0, headers: ['X-Trace-Id' => 'trace-42'], cancellation: CancellationToken::after(8.0), )); // The ...WithResponse() variant returns the decoded body with the HTTP details. echo count($response->data->data), ' companies, HTTP ', $response->status, ', request id ', $response->requestId ?? 'n/a', PHP_EOL; if ($response->rateLimit !== null) { echo $response->rateLimit->remaining, ' of ', $response->rateLimit->limit, ' requests left', PHP_EOL; } $client->close();
Requests are sent by a built-in curl transport that reuses its connection, enforces the timeouts and delivers streams incrementally. Pass httpClient to use any PSR-18 client instead.
- A Guzzle client keeps the per-request timeouts, never follows redirects and delivers stream events as they arrive, whatever its handler. With Guzzle's stream handler (what a default client uses for streams while
allow_url_fopenis on) the idle timeout and cancellation are exact. With Guzzle's curl handlers (an explicitCurlHandlerorCurlMultiHandler, or any default client whenallow_url_fopenis off) the idle timeout is rounded up to whole seconds and fires about five seconds late, a cancellation is noticed when the next data arrives, and on PHP 8.2 and 8.3 a stream you leave early is closed by$stream->close(), by the next request or at the end of the script rather than at once (PHP 8.4 and newer close it at once). A handler of your own that buffers the response delivers the events only when the response is complete; if it wraps a curl handler, say so withtransport: new GuzzleTransport($guzzle, curlHandler: true). - With any other PSR-18 client, timeouts and redirects must be configured on that client, cancellation is only checked between requests, and a client that buffers responses delivers stream events only when the response ends.
transport accepts your own implementation of Tungo\Sdk\Transport\Transport. The PSR-17 factories are found by php-http/discovery or can be passed explicitly.
Cancellation is cooperative: Tungo\Sdk\CancellationToken (or your own Cancellation) is polled while the SDK waits for the network, a retry delay or a poll, and raises CancelledError.
Errors
Every failure is a subtype of the SDK's base error. An HTTP error response becomes an API error chosen by the error.type field of the response body, falling back to the HTTP status when the body is not the API's error envelope:
error.type |
Status |
|---|---|
invalid_request_error |
400 |
authentication_error |
401 |
payment_required_error |
402 |
permission_error |
403 |
not_found_error |
404 |
conflict_error |
409 |
gone_error |
410 |
limit_error |
429 |
api_error |
500 |
unavailable_error |
503 |
An API error carries the status, type, code, message, request id, field errors, the parsed retry-after delay, the response headers and the raw body. Problems that happen without a response have their own types: connection error, timeout, decode error (a successful response whose body is not valid), and configuration or validation error (raised before any request).
use Tungo\Sdk\Error\ApiError; use Tungo\Sdk\Error\ConnectionError; use Tungo\Sdk\Error\LimitError; use Tungo\Sdk\Error\NotFoundError; use Tungo\Sdk\Error\TimeoutError; use Tungo\Sdk\Error\TungoError; use Tungo\Sdk\TungoClient; $client = new TungoClient(companyId: 'co_01h455vb4pex5vsknk084sn02q'); try { $proposal = $client->proposals->get('P-000042'); echo $proposal->title, PHP_EOL; } catch (NotFoundError $e) { echo 'No such proposal (request id ', $e->requestId ?? 'n/a', ')', PHP_EOL; } catch (LimitError $e) { // $e->extra holds the other properties of the error object, such as resets_at. echo 'Limit reached, retry in ', $e->retryAfter ?? 0, ' s', PHP_EOL; } catch (ApiError $e) { // Every HTTP error: status, error.type, error.code, message, request id, field errors, headers, raw body. echo $e->status, ' ', $e->type, ' ', $e->errorCode ?? '-', ': ', $e->apiMessage, PHP_EOL; foreach ($e->fields as $field) { echo ' ', $field->path, ': ', $field->message, PHP_EOL; } } catch (ConnectionError|TimeoutError $e) { echo 'Network problem: ', $e->getMessage(), PHP_EOL; } catch (TungoError $e) { // The base class: DecodeError, ConfigError, ValidationError, CancelledError, ... echo get_class($e), ': ', $e->getMessage(), PHP_EOL; }
All error classes live in Tungo\Sdk\Error: ApiError with InvalidRequestError, AuthenticationError, PaymentRequiredError, PermissionError, NotFoundError, ConflictError, GoneError, LimitError, ServerError (api_error) and UnavailableError; ConnectionError, TimeoutError (PollTimeoutError for the wait helpers), DecodeError, ConfigError, ValidationError, CancelledError, PaginationError and WebhookVerificationError. ApiError::$extra holds every other property of the error object, such as resets_at or permission.
DecodeError means that the API answered with a success status but the body is not what the contract promises (not JSON, cut short, a required field missing). The request itself was accepted and carried out, so do not repeat a write because of it: $e->status, $e->requestId and $e->body (the raw answer) tell what the server did. The SDK never retries it. A chat turn can also fail inside a 200 answer: ask() raises the first entry of the turn's errors as the matching ApiError (pass throwOnError: false to read ->errors yourself), exactly as accumulate() does for a stream.
Exceptions never carry the API key: the request is not attached to them, the arguments that hold it are marked #[\SensitiveParameter], and an exception of your HTTP client (which holds the request) is replaced by a summary in getPrevious().
Retries and timeouts
A request is retried up to 2 times (3 attempts) on connection errors, timeouts and the statuses 408, 429, 500, 502, 503 and 504, but only when repeating it is safe: GET, HEAD, PUT and DELETE requests, and POST or PATCH requests of the operations whose contract declares an Idempotency-Key. For those the SDK generates a random key and reuses it for every attempt; you can pass your own. Other operations send no key, not even one you pass, because the API would ignore it.
The delay between attempts is min(0.5 s × 2^attempt, 8 s) with random jitter. A positive retry-after-ms or Retry-After response header overrides it; if the server asks for more than 60 s the SDK does not wait and raises the error with the delay filled in.
The timeout covers the whole request including reading the body. Streaming calls have no total timeout, only a connect timeout and an idle timeout of 120 s. Cancellation supplied by the caller is combined with the timeout.
use Tungo\Sdk\RequestOptions; use Tungo\Sdk\TungoClient; // For the whole client: up to 5 retries, 20 s per request. $client = new TungoClient(companyId: 'co_01h455vb4pex5vsknk084sn02q', maxRetries: 5, timeout: 20.0); // GET is retried automatically on connection errors, timeouts, 408, 429 and 5xx. $companies = $client->companies->list(); echo count($companies->data), ' companies', PHP_EOL; // A POST without an idempotency key is never retried. Passing a key makes it safe to repeat; // the same key is sent with every attempt. $run = $client->automations->cancelRun('run_01h455vb4pex5vsknk084sn02q', options: new RequestOptions( idempotencyKey: 'cancel-run_01h455vb4pex5vsknk084sn02q', maxRetries: 2, timeout: 5.0, )); echo 'run ', $run->id, ' cancel requested: ', $run->cancelRequested ? 'yes' : 'no', PHP_EOL;
retryBaseDelay and retryMaxDelay of the client change the backoff. A Retry-After of zero or in the past does not mean "at once": the computed backoff is used. With a PSR-18 client other than Guzzle the timeout of the client applies.
Pagination
List operations use cursor pagination ({ data, next_cursor }). The plain method returns one typed page that knows whether there is a next page and can fetch it. The auto-paging variant is a lazy iterator: it requests the next page only when needed, carries your filters over, and stops fetching as soon as you stop iterating.
use Tungo\Sdk\TungoClient; $client = new TungoClient(companyId: 'co_01h455vb4pex5vsknk084sn02q'); // One page: the items, the cursor, and the whole response body with its totals. $page = $client->proposals->list(limit: 50, status: 'previewed'); foreach ($page->data as $proposal) { echo $proposal->id, ' ', $proposal->title, ' ', $proposal->total->formatted, PHP_EOL; } echo 'total ', $page->body->total, ', more pages: ', $page->hasNext() ? 'yes' : 'no', PHP_EOL; $next = $page->next(); // null on the last page // Every item of every page, fetched lazily; filters are carried over. foreach ($client->proposals->listAll(limit: 50, status: 'previewed') as $proposal) { echo $proposal->id, PHP_EOL; if ($proposal->blockerCount > 0) { break; // no further page is requested } }
Page is iterable and countable; $page->body is the whole typed response and $page->response has the status, headers, request id and rate limit. ...All() raises PaginationError instead of looping forever when the API returns a cursor it has already returned.
Streaming
Streaming operations use server-sent events and deliver typed events as they arrive. Leaving the loop early or closing the stream closes the connection. A stream request is retried only before its first byte; an error response is raised as the same typed API error as for ordinary calls. A chat stream that ends before its final event is reported as an error, never as a finished answer.
use Tungo\Sdk\Model\ChatEventDone; use Tungo\Sdk\Model\ChatEventText; use Tungo\Sdk\Model\ChatEventTool; use Tungo\Sdk\Model\ChatMessageRequest; use Tungo\Sdk\TungoClient; $client = new TungoClient(companyId: 'co_01h455vb4pex5vsknk084sn02q'); $stream = $client->chat->createStream(new ChatMessageRequest(message: 'Сверь взаиморасчёты с ТОО «Ромашка»')); echo 'dialog ', $stream->dialogId() ?? 'n/a', PHP_EOL; foreach ($stream as $event) { if ($event instanceof ChatEventText) { echo $event->delta; } elseif ($event instanceof ChatEventTool) { echo PHP_EOL, '[tool ', $event->name, ']', PHP_EOL; } elseif ($event instanceof ChatEventDone) { echo PHP_EOL, 'stop reason: ', $event->stopReason ?? 'none', PHP_EOL; } } // Or let the SDK accumulate the turn; an error event of the stream is thrown as a typed ApiError. $result = $client->chat ->createStream(new ChatMessageRequest(message: 'Итог одной строкой')) ->accumulate(); echo $result->text, PHP_EOL;
chat->listEvents(), feed->listEvents() and batches->listEvents() return the same kind of stream; pass lastEventId: to resume one after a disconnect. rawEvents() yields the events as received (name, data, id, retry). The idle timeout is 120 s and can be set per client (streamIdleTimeout) or per request (RequestOptions::$idleTimeout).
A turn is complete only when its done event has arrived. If the stream ends without one, the loop over the events simply ends, but accumulate() raises DecodeError ("stream ended before completion", with the text received so far in $e->body) instead of returning half an answer. A 2xx answer to a stream call that is not text/event-stream raises DecodeError as well. The exception is a finished chat turn that the API replays for a repeated Idempotency-Key (application/json with Idempotent-Replayed: true): its stored result arrives as the events of that turn, ending with next (follow-ups and the next action, when it has them) and done.
Webhooks
Outgoing webhooks are signed according to the Standard Webhooks specification (Svix). The verify helper takes the raw request body and the headers (webhook-* or svix-*), checks the HMAC-SHA256 signature in constant time and the timestamp within 300 s of the system clock, and returns the typed event. Any problem is reported as a webhook verification error. If no secret is passed, the client's webhook secret is used.
The send-hook helper posts an event to an incoming hook (/hooks/{token}) and, when given a secret, signs the exact bytes it sends in the X-Signature: sha256=<hex> header. The API key is never sent to hooks.
use Tungo\Sdk\Error\WebhookVerificationError; use Tungo\Sdk\Model\WebhookEventProposalWritten; use Tungo\Sdk\TungoClient; use Tungo\Sdk\TungoWebhooks; // The secret comes from webhookSecret or TUNGO_WEBHOOK_SECRET. $client = new TungoClient(); // Verify with the RAW body. Headers can be $_SERVER, getallheaders() or a PSR-7 request. $rawBody = (string) file_get_contents(getenv('WEBHOOK_BODY_FILE') ?: 'php://input'); try { $event = $client->verifyWebhook($rawBody, $_SERVER); if ($event instanceof WebhookEventProposalWritten) { echo 'proposal ', $event->data->proposalId, ' was written to 1C', PHP_EOL; } else { echo 'received ', get_class($event), PHP_EOL; } } catch (WebhookVerificationError $e) { http_response_code(400); echo 'rejected: ', $e->reason, PHP_EOL; } // Send an event to an incoming hook. The body is signed in the X-Signature header. $accepted = $client->sendHook( 'hk_3f2a9c0d1e4b4c5d8e7f6a5b4c3d2e1f', ['order_id' => 1532, 'amount_tiyn' => 4500000], secret: 'hksec_test_secret_key_for_hmac_256', eventId: 'order-1532', ); echo 'accepted as ', $accepted->eventId, PHP_EOL; // On your side of an incoming hook, check a signature like this: $valid = TungoWebhooks::verifyHookSignature('{"order_id":1532}', 'sha256=...', 'hksec_test_secret_key_for_hmac_256'); echo $valid ? 'valid' : 'invalid', PHP_EOL;
TungoWebhooks::verifyWebhook() is the same check as a static method. An event of a type this SDK version does not know is returned as WebhookEventUnknown. A body that is not a JSON object with a non-empty type is rejected with the reason invalid_payload: answer it with a 2xx if you do not want it redelivered. An event whose signature is valid but whose data does not match this SDK version's contract (a missing required field, a value of the wrong type) is returned as WebhookEventUnknown with its original type and all of its data, like a type the SDK does not know.
The Idempotency-Key of sendHook() is the key of the request options, else eventId, else a random UUID, so a repeated event id is deduplicated by the API.
Money
The API expresses amounts in tiyn (1/100 of a tenge) as 64-bit integers. The money helper converts decimal strings and exact decimal values to tiyn without floating-point error, rounding half away from zero ("1.005" → 101), and formats tiyn for display (4500000 → 45 000,00 ₸). Invalid input raises a typed error.
use Tungo\Sdk\Error\ValidationError; use Tungo\Sdk\TungoMoney; echo TungoMoney::toTiyn('45000.50'), PHP_EOL; // 4500050 echo TungoMoney::toTiyn('1.005'), PHP_EOL; // 101 (half away from zero) echo TungoMoney::formatTiyn(4500050), PHP_EOL; // 45 000,50 ₸ echo TungoMoney::formatTiyn(-50, 'USD'), PHP_EOL; // -0,50 $ echo TungoMoney::toTenge(4500050), PHP_EOL; // 45000.50 echo TungoMoney::isValidBinIin('000000000000') ? 'valid' : 'invalid', PHP_EOL; try { TungoMoney::toTiyn('12,5'); } catch (ValidationError $e) { echo $e->getMessage(), PHP_EOL; }
toTiyn() also accepts an int (whole tenge) and a float, which is converted through its shortest round-trip decimal form, so 1.005 gives 101 on every PHP version.
Models
Requests and responses are final readonly classes in Tungo\Sdk\Model, built with named arguments and returned by every method:
Model::fromArray($array)builds a model from decoded JSON;->toArray()andjson_encode($model)give back the wire form.- Enumerations are backed enums. A property of an enum type holds the enum case, or the raw string when the API sent a value this SDK version does not know, so new values never break decoding.
- Unions with a discriminator are interfaces (
ChatEvent,WebhookEvent, ...) implemented by one class per variant, plus an...Unknownclass for future variants. Other unions are native PHP union types. - Unknown response properties are ignored. Integers are 64-bit and never pass through floats. Dates stay RFC 3339 strings.
- To send an explicit
nullfor an optional property (PATCH semantics), list its wire name inexplicitNulls:new WebhookEndpointPatch(explicitNulls: ['channels']). An empty array is sent as[].
Long-running helpers
// Start a scenario and wait for its run; PollTimeoutError after 120 s. $run = $client->runScenarioAndWait('scn_01h455vb4pex5vsknk084sn02q', timeout: 120.0); // Or wait for something that is already running. $run = $client->waitForRun('run_01h455vb4pex5vsknk084sn02q'); $job = $client->waitForBatch('bat_01h455vb4pex5vsknk084sn02q', timeout: 600.0); // Upload a bank statement to be parsed into proposals. $job = $client->uploadBankStatement('statement.txt', (string) file_get_contents('statement.txt'), isPlainText: true);
The wait helpers poll with a pause that grows from 1 s to 5 s and raise Tungo\Sdk\Error\PollTimeoutError (a TimeoutError) when the deadline passes: timeout, 600 s by default (TungoClient::DEFAULT_WAIT_TIMEOUT). CancelledError is raised when the cancellation of the request options fires.
API reference
Generated from the Tungo Platform API contract: 13 resources, 90 methods, 22 webhook event types.
| Resource | Description | Methods |
|---|---|---|
$client->workspaces |
Organisations (workspaces) the API key can access | list |
$client->companies |
Companies of an organisation and their profiles | list · get |
$client->platform |
Platform balance, ledger, usage reports, limits, audit log and model rates | listUsageRates · getAccount · getAudit / getAuditAll · getLedger / getLedgerAll · listLimits · listRequests / listRequestsAll · getUsage |
$client->apiKeys |
The current API key and its usage statistics | getCurrent · getCurrentUsage · getUsage |
$client->webhooks |
Outgoing webhooks of an organisation (Svix): endpoints, event catalogue, deliveries and signature check | listEndpoints / listEndpointsAll · createEndpoint · getEndpoint · updateEndpoint · deleteEndpoint · listEndpointAttempts / listEndpointAttemptsAll · rotateEndpointSecret · testEndpoint · listEventTypes · listMessages / listMessagesAll · listMessageAttempts / listMessageAttemptsAll · resendMessage · getUsage |
$client->chat |
Conversations with the AI accountant: questions, streaming chat (SSE), token counting and history | list / listAll · create / createStream · countTokens · listMentions · getRatingOptions · listSuggestions · get · update · delete · answer / answerStream · listEvents · rateMessage · deleteMessageRating · rate · dismissRating · stop |
$client->proposals |
Documents proposed by the AI accountant: review, approval for posting to 1C, and rejection | list / listAll · approveBatch · get · approve · reject · requestApproval |
$client->feed |
The company's feed of events and proposals (pages and a live SSE stream) | get / getAll · listEvents |
$client->batches |
Processing batches for bank statements, incoming e-invoices and DirectBank | list / listAll · createBankStatement · createEsfPurchases · get · listEvents · stop · getSummary · listStatements / listStatementsAll · pollDirectbank |
$client->esf |
Incoming and outgoing electronic invoices (the ESF system) | getIncoming / getIncomingAll · getOutgoing / getOutgoingAll |
$client->forms |
Tax forms (FNO 100.00 and others): building, ISNA checks and data gaps | list · build · listDataGaps · getIsna · listNotes |
$client->onec |
Status of the company's connection to its 1C database | getStatus |
$client->automations |
Company automations: incoming hooks, scenarios, step chains, runs and the AI autonomy level | postHook · listHooks · createHook · getHook · updateHook · deleteHook · rotateHook · testHook · listRuns / listRunsAll · getRun · cancelRun · retryRun · listScenarios · createScenario · getScenario · putScenario · updateScenario · deleteScenario · runScenario · getState · putState · getAutonomy · putAutonomy |
Other SDKs
The same API is available in ten languages. All SDKs are generated from one contract and behave the same way.
| Language | Repository | Package |
|---|---|---|
| TypeScript | tungo-sdk-typescript | @tungoai/sdk |
| Python | tungo-sdk-python | tungoai |
| Go | tungo-sdk-go | github.com/TungoAI/tungo-sdk-go |
| Rust | tungo-sdk-rust | tungo-sdk |
| PHP | tungo-sdk-php | tungo/sdk |
| Java | tungo-sdk-java | kz.tungo:tungo-sdk |
| Kotlin | tungo-sdk-kotlin | kz.tungo:tungo-sdk-kotlin |
| Swift | tungo-sdk-swift | TungoSDK |
| Ruby | tungo-sdk-ruby | tungo-sdk |
| .NET | tungo-sdk-dotnet | Tungo.Sdk |
Versioning and support
This library follows Semantic Versioning. While the major version is 0, a minor release may contain breaking changes; they are always listed in the changelog. This README describes version 0.3.1.
The code is generated: please report problems and ideas in the issue tracker rather than opening pull requests against generated files. To report a vulnerability, follow the security policy.