The PHP SDK of the Oscar accounting API.
Requires
- php: >=8.2
- ext-curl: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.10
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Suggests
- psr/http-client: To send the requests through your own PSR-18 HTTP client
- psr/http-factory: To build the requests with your own PSR-17 factories
Provides
None
Conflicts
None
Replaces
None
README
The PHP SDK of the Oscar accounting API.
- PHP 8.2 and later, 64-bit.
- No Composer dependency at run time: only the PHP extensions
curlandjson. So it cannot conflict with another package, also in a WordPress plugin. - Typed models for every request and response, checked by PHPStan at level
max. - Your own PSR-18 HTTP client if you want one (Guzzle, Symfony HttpClient, the client of Laravel).
Install
composer require oscaraccountant/sdk
Quick start
use OscarAccountant\Oscar; $oscar = new Oscar(); // reads OSCAR_API_KEY $me = $oscar->me->retrieve(); foreach ($oscar->invoices->list($companyId, state: 'issued') as $invoice) { echo $invoice->id, ' ', $invoice->number, "\n"; } $pnl = $oscar->reports->profitAndLoss($companyId, dateFrom: '2026-09-01', dateTo: '2026-09-30');
A method takes the ids of the path first, in the order of the path. Give
every other argument by name: the query parameters, the fields of an
upload, body and options. A date argument takes a \DateTimeInterface
or a YYYY-MM-DD string.
Authentication
The client finds its credential in this order. The first match wins.
- The option
apiKey: 'osk_...', ortokenProvider: $callback. A token provider returns an access token. The client calls it before each request and each retry. - The environment variable
OSCAR_API_KEY. - The login of the
oscarCLI, only when you ask for it (see below). - Otherwise each call throws
AuthenticationErrorand sends nothing.
An empty string counts as not set. The client sends the credential as
Authorization: Bearer <value>.
Use the login of the oscar CLI
On a developer machine, you can use the login of the oscar CLI instead of
an API key:
npx @oscaraccountant/cli login
$oscar = new Oscar(credential: 'cli'); // or set OSCAR_USE_CLI_LOGIN=1 $oscar = new Oscar(credential: 'cli', profile: 'work'); // or set OSCAR_PROFILE=work
The SDK never starts a process unless you ask for it. So a server with no
OSCAR_API_KEY fails, and does not use the login of a developer by accident.
When you ask for it, the SDK:
- finds
oscarin the directories ofPATHonly, never in the current directory, and runs it by its absolute path throughproc_open, with no shell; - runs
oscar --versiononce and requiresoscar-cli <version>; - runs
oscar auth token --json --base-url <base URL>(and--profile), with a timeout of 30 seconds; - refuses a token for another base URL;
- keeps the token in memory until 60 seconds before it expires. After a 401, it asks the CLI once more before it throws the error;
- never logs the token and never writes it to disk.
Many shared hosts disable proc_open. There the CLI login throws
AuthenticationError: use an API key.
Configuration
| Option | Default | What it does |
|---|---|---|
apiKey |
OSCAR_API_KEY |
The API key |
tokenProvider |
none | A callable that returns an access token |
credential |
none | 'cli': use the login of the oscar CLI |
profile |
OSCAR_PROFILE |
The profile of the CLI login |
baseUrl |
OSCAR_BASE_URL, else Oscar::DEFAULT_BASE_URL (https://oscaraccountant.com) |
The origin of the API |
timeout |
60.0 |
The timeout of one attempt, in seconds |
maxRetries |
2 |
The retries after the first attempt. 0 turns them off |
headers |
none | Headers for every call |
httpClient, requestFactory, streamFactory |
none | A PSR-18 client and its PSR-17 factories (see below) |
transport |
CurlTransport |
A transport of your own (OscarAccountant\Http\Transport) |
The base URL must use https. http is allowed only for localhost,
127.0.0.1, [::1] and hosts that end in .personal.
Each method takes options: new RequestOptions(...) for one call:
use OscarAccountant\RequestOptions; $oscar->invoices->record($companyId, $payload, options: new RequestOptions( idempotencyKey: 'my-key', timeout: 10.0, maxRetries: 0, headers: ['X-Trace' => 'abc'], ));
headers cannot replace Authorization or Idempotency-Key.
Models and enums
The models are in OscarAccountant\Generated\Models, the enums in
OscarAccountant\Generated\Enums. A property of a model has the name of the
field in the API, so it reads the same as the API reference:
$page = $oscar->invoices->list($companyId, limit: 50)->firstPage(); echo count($page->items), ' ', $page->next_cursor;
- A model keeps a field that this version of the SDK does not know:
$model->extra()gives it.$model->toArray()gives the model as decoded JSON, with the names of the API. - In such a field and in a
mixedproperty (untyped JSON), a JSON object is a\stdClassand a JSON array is a PHP array. Sojson_encodewrites{}back as{}, not as[]. - An enum field is the enum case, or a string when this version does not
know the value. So a new value of the server does not break your code, and
a
matchon it needs adefaultarm. - Money and other decimals are strings, never a
float. A date is aYYYY-MM-DDstring and a date-time an RFC 3339 string.
A request body is a model with named arguments. A field you do not give is
not sent; a field you give as null is sent as null:
use OscarAccountant\Generated\Models\CancelInvoiceBodyRequest; $oscar->invoices->cancel($companyId, $invoiceId, new CancelInvoiceBodyRequest(reason: 'Entered twice'));
Model::fromArray($data) makes a model from decoded JSON, for example a
body you keep in a configuration file.
Pagination
A list method returns a Paginator:
$invoices = $oscar->invoices->list($companyId, limit: 50); $page = $invoices->firstPage(); // one page: $page->items, $page->next_cursor foreach ($invoices as $invoice) { /* ... */ } // every item of every page foreach ($invoices->pages() as $page) { /* ... */ }
The paginator sends a request only when you need the next item. Each page is
a separate GET with its own retries.
Errors
Every error implements the interface OscarAccountant\Errors\OscarError, so
catch (OscarError $e) catches all of them.
| Error | When |
|---|---|
ApiError |
The API answered with a status of 400 or above |
BadRequestError |
400 |
AuthenticationError |
401, or no credential (then $status is null) |
PermissionDeniedError |
403 |
NotFoundError |
404 |
ConflictError |
409 |
UnprocessableEntityError |
422 |
RateLimitError |
429 |
InternalServerError |
500 and above |
ConnectionError |
No response: DNS, a refused or reset connection, TLS |
TimeoutError |
An attempt ran out of time |
ResponseValidationError |
A success response that does not match its type |
ConfigurationError |
An option that the client cannot use |
UsageError |
A call that the client cannot send, for example an empty id |
An ApiError has $status, $code (stable and machine-readable, for
example validation_error; getCode() gives it too), $title, $detail,
$errors (one entry per invalid field) and $requestId (the X-Request-Id
of the response):
use OscarAccountant\Errors\ApiError; use OscarAccountant\Errors\NotFoundError; try { $oscar->invoices->retrieve($companyId, $invoiceId); } catch (NotFoundError) { // ... } catch (ApiError $error) { echo $error->status, ' ', $error->code, ' ', $error->detail, ' ', $error->requestId; }
Retries and idempotency
The client sends a request again only when this is safe: a GET, or a
request with an Idempotency-Key. It retries on a connection error, a
timeout, and the statuses 408, 429, 500, 502, 503, 504, and 409 with the
code idempotency_in_progress.
- The delay before retry
nismin(8, 0.5 * 2^(n-1))seconds, less up to 25 percent of random jitter. - When the response has
Retry-After, that delay wins. ARetry-Afterof more than 60 seconds ends the retries. - The client sends an
Idempotency-Key(a UUID version 4) on every operation that accepts it, and the same key on every retry. Give your own key withnew RequestOptions(idempotencyKey: ...).
Files
Upload a file with files->upload:
use OscarAccountant\Upload; $stored = $oscar->files->upload($companyId, file: Upload::fromPath('invoice.pdf', contentType: 'application/pdf'), kind: 'purchase_invoice'); $stored = $oscar->files->upload($companyId, file: Upload::fromString($bytes, 'invoice.pdf', 'application/pdf')); $stored = $oscar->files->upload($companyId, file: Upload::fromStream($handle));
The SDK reads the file once, so a retry sends the same bytes.
Download the bytes of a file with files->download:
$download = $oscar->files->download($companyId, $fileId); echo $download->filename, ' ', $download->contentType, ' ', $download->contentLength; $download->saveTo('/tmp/' . ($download->filename ?? 'file'));
$download->stream() gives the bytes in chunks as they arrive, and
$download->contents() gives all of them. Read a download once.
Your own HTTP client (PSR-18)
By default the SDK sends its requests with the PHP extension curl. To send
them through the HTTP client of your application, give a PSR-18 client and
its PSR-17 factories:
$factory = new \GuzzleHttp\Psr7\HttpFactory(); $oscar = new Oscar( httpClient: new \GuzzleHttp\Client(['timeout' => 60]), requestFactory: $factory, streamFactory: $factory, );
PSR-18 has no timeout for one request. With a PSR-18 client, the timeout is
the one of the client you built, and the option timeout has no effect.
In a WordPress plugin
The SDK has no Composer dependency at run time, defines no global function
and no global constant, and uses ext-curl, which WordPress uses too. So you
can bundle it in a plugin with no PHP-Scoper or Strauss step. If your plugin
must send through wp_remote_request, write a class that implements
OscarAccountant\Http\Transport and give it as transport:.
Development
The resources and the models in src/Generated/ are generated from the
OpenAPI schema of the API. Do not edit them. The hand-written runtime is the
rest of src/.
The gates:
composer install composer validate --strict vendor/bin/php-cs-fixer check vendor/bin/phpstan analyse # level max vendor/bin/phpunit --coverage-clover build/clover.xml # with pcov or Xdebug php tests/check-coverage.php build/clover.xml 90
vendor/bin/phpunit also has two suites that need files of the source
repository of Oscar. In this repository they are skipped:
| Suite | What it does |
|---|---|
tests/Conformance/ |
Plays every conformance case of the shared client rules with a fake transport |
tests/Integration/ (group integration) |
Starts the mock server of the API as a process on a free port and calls every operation over real HTTP |
Documentation
The guides and the reference of the API are at https://oscaraccountant.com/docs.
License
MIT. Copyright (c) 2026 OscarAI OÜ.