blob-solutions / vcr-am-sdk
Official PHP SDK for the VCR.AM Virtual Cash Register API — fiscalize sales, refunds, and prepayments through Armenia's State Revenue Committee.
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- cuyz/valinor: ^2.4
- guzzlehttp/guzzle: ^7.15.2
- nyholm/psr7: ^1.8
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- laravel/pint: ^1.29
- pestphp/pest: ^3.7
- pestphp/pest-plugin-type-coverage: ^3.5
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Official PHP SDK for the VCR.AM Virtual Cash Register API. Fiscalize sales, refunds, and prepayments through Armenia's State Revenue Committee — without touching XML, PSR-7, or wire-format quirks.
A native sibling to the TypeScript SDK. Same endpoints, same error semantics, same response validation philosophy — adapted to idiomatic PHP 8.2+.
Status
Pre-release. API is being developed in lockstep with the TypeScript SDK. While the package is on
0.x, every minor release may introduce breaking changes — pin tightly until1.0. See CHANGELOG.md for release notes.
⚠️ Do not use v0.1.0. The very first release shipped with the wrong auth header and is rejected by every production request. Pin
^0.1.1or later.
Requirements
- PHP 8.2 or newer (8.1 hit security EOL on 2025-12-31)
- Composer 2.x
- A VCR.AM account and API key — sign up at vcr.am
Installation
composer require blob-solutions/vcr-am-sdk
The package ships with sensible defaults (Guzzle 7 as the PSR-18 HTTP client, nyholm/psr7 as the PSR-7/PSR-17 implementation). If your application already uses different implementations, the SDK will discover and reuse them via php-http/discovery.
Quick start
Register a sale of two loaves of bread, paid in cash:
use BlobSolutions\VcrAm\Input\Buyer; use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\Offer; use BlobSolutions\VcrAm\Input\RegisterSaleInput; use BlobSolutions\VcrAm\Input\SaleAmount; use BlobSolutions\VcrAm\Input\SaleItem; use BlobSolutions\VcrAm\Unit; use BlobSolutions\VcrAm\VcrClient; $client = new VcrClient(apiKey: $_ENV['VCR_AM_API_KEY']); $response = $client->registerSale(new RegisterSaleInput( cashier: CashierId::byDeskId('desk-1'), items: [ new SaleItem( offer: Offer::existing('sku-bread'), quantity: '2', price: '750', unit: Unit::Piece, ), ], amount: new SaleAmount(cash: '1500'), buyer: Buyer::individual(), )); echo "Sale {$response->saleId} fiscalised as {$response->fiscal}\n"; echo "Public receipt: https://vcr.am/{$response->urlId}\n";
Every input DTO is constructor-validated, so malformed payloads fail at the call site — not after a round-trip to the SRC. All decimal monetary values are passed as strings to preserve precision over the wire (see Monetary precision below).
Pairing a store with a register
If your software is installed by merchants — a WooCommerce plugin, a POS, a
booking system — do not ask them to paste an API key. PairingClient gets you
one through the merchant's own browser: they click "connect" in your software,
approve on vcr.am against a register they already manage, and come back paired.
It takes no API key, because obtaining the first one is the point.
use BlobSolutions\VcrAm\Input\RegisterPairingRequestInput; use BlobSolutions\VcrAm\Pairing\CodeVerifier; use BlobSolutions\VcrAm\PairingClient; $pairing = new PairingClient(integration: 'my-store/1.0'); // 1. Before you redirect. Persist both against this merchant's session. $verifier = CodeVerifier::generate(); $state = bin2hex(random_bytes(16)); $request = $pairing->registerRequest(new RegisterPairingRequestInput( redirectUri: 'https://shop.example/wp-admin/admin.php?page=vcr-am', storeName: 'Example Shop', state: $state, codeChallenge: CodeVerifier::challengeFor($verifier), )); // 2. Send the merchant's browser to $request->connectUrl. // 3. They return to your redirectUri with `code` and `state`. // Check `state` against what you stored, then: $paired = $pairing->exchangeCode($code, $verifier); // Store this now. It is shown once and is never retrievable again. $apiKey = $paired->apiKey; echo "Paired with {$paired->registerName} (VCR {$paired->vcrId})\n";
Both calls run on your server. The verifier is what proves the exchange is you, so it must never reach the merchant's machine — a code intercepted in the redirect is worthless without it.
Three things worth knowing:
stateis yours, and the SDK does not generate it. It comes back untouched on the redirect and is how you tell your own pairing from a replayed or forged one, which means it has to be checked against something you persisted.redirectUriis shown to the merchant before they approve, so make it a page they would recognise as their own shop. It must be absolutehttps(plainhttponly on loopback), with no fragment and no credentials.- Every exchange failure returns one identical message. Unknown, already used, expired and wrong-verifier are deliberately indistinguishable, so a stolen code cannot be probed for liveness. Do not branch on the text.
The returned key is register-wide and does not expire for two years;
$paired->expiresAt is when it does. $paired->crn is null for a register
that has not finished activating with the tax service, which is worth showing
the merchant — calls with that key will be refused until it has.
Endpoints
listCashiers(): list<CashierListItem>
Returns every cashier registered for the account, with a per-language map of names.
$cashiers = $client->listCashiers(); foreach ($cashiers as $cashier) { $hyName = $cashier->name['hy']->content ?? '(no Armenian name)'; echo "{$cashier->deskId} (#{$cashier->internalId}): {$hyName}\n"; }
createCashier(CreateCashierInput $input): CreateCashierResponse
Provisions a new cashier under the account. The password is the terminal PIN (4–8 digits) — held in a private property and redacted from var_dump / print_r.
use BlobSolutions\VcrAm\Input\CreateCashierInput; use BlobSolutions\VcrAm\Input\LocalizedName; use BlobSolutions\VcrAm\LocalizationStrategy; $response = $client->createCashier(new CreateCashierInput( name: new LocalizedName( value: ['hy' => 'Աննա Գրիգորյան', 'en' => 'Anna Grigoryan'], localizationStrategy: LocalizationStrategy::Transliteration, ), password: '1234', )); echo "Cashier #{$response->id} provisioned (deskId={$response->deskId})\n";
createDepartment(CreateDepartmentInput $input): CreateDepartmentResponse
Creates a department (tax-regime-bound bucket of offers).
The department, not the offer, decides the tax regime printed on the receipt. Every register is created with one department per regime — VAT, VAT-exempt, turnover tax, micro-enterprise — numbered in that order, which makes new Department(1) the VAT one on every register whether or not the merchant owes VAT. Call listDepartments() once, read taxRegime, and set the offer's defaultDepartment to the id that matches how the business is actually registered; sale items then leave department out and inherit it. Nothing rejects a sale booked under the wrong regime, and a fiscal receipt can only be refunded and reissued, never corrected.
use BlobSolutions\VcrAm\Input\CreateDepartmentInput; use BlobSolutions\VcrAm\Input\LocalizedName; use BlobSolutions\VcrAm\LocalizationStrategy; use BlobSolutions\VcrAm\TaxRegime; $response = $client->createDepartment(new CreateDepartmentInput( taxRegime: TaxRegime::Vat, title: new LocalizedName( value: ['hy' => 'Հացաբուլկեղեն', 'ru' => 'Хлебобулочные', 'en' => 'Bakery'], localizationStrategy: LocalizationStrategy::Translation, ), externalId: 'erp-dept-bakery', )); echo "Department #{$response->department} created — {$response->message}\n";
createOffer(CreateOfferInput $input): CreateOfferResponse
Adds a standalone offer (product or service) to the account catalogue.
use BlobSolutions\VcrAm\Input\CreateOfferInput; use BlobSolutions\VcrAm\Input\Department; use BlobSolutions\VcrAm\Input\LocalizedName; use BlobSolutions\VcrAm\LocalizationStrategy; use BlobSolutions\VcrAm\OfferType; use BlobSolutions\VcrAm\Unit; $response = $client->createOffer(new CreateOfferInput( type: OfferType::Product, classifierCode: '01.01.01', title: new LocalizedName( value: ['hy' => 'Հաց', 'en' => 'Bread'], localizationStrategy: LocalizationStrategy::Translation, ), defaultMeasureUnit: Unit::Piece, defaultDepartment: new Department(5), externalId: 'sku-bread', )); echo "Offer #{$response->offerId} created\n";
listOffers(?string $externalId, ?OfferType $type, bool $includeArchived): list<OfferListItem>
Reads the offer catalogue (up to 500 rows). Handy for checking whether an offer already exists (by externalId) before creating it. Archived offers are excluded unless $includeArchived is true.
use BlobSolutions\VcrAm\OfferType; $existing = $client->listOffers(externalId: 'sku-bread'); if ($existing === []) { // ... create it } $products = $client->listOffers(type: OfferType::Product);
getOffer(int $offerId): OfferListItem
Reads a single offer by its internal numeric id.
$offer = $client->getOffer(7); echo "{$offer->id}: {$offer->classifierCode} ({$offer->defaultMeasureUnit})\n";
updateOffer(int $offerId, OfferTitle $title): OfferListItem
Renames an offer's title. Affects only future receipts — already-issued receipts keep the title they were created with, and the SRC fiscal record is unchanged.
use BlobSolutions\VcrAm\Input\OfferTitle; use BlobSolutions\VcrAm\LocalizationStrategy; $offer = $client->updateOffer(7, OfferTitle::localized( ['hy' => 'Լատտե', 'en' => 'Latte'], LocalizationStrategy::Translation, ));
searchClassifier(string $query, OfferType $type, Language $language): list<ClassifierSearchItem>
Fuzzy-searches the SRC classifier taxonomy. Useful for populating an autocomplete in an offer-creation UI. Language::Multi is rejected — pick a concrete language.
use BlobSolutions\VcrAm\Language; use BlobSolutions\VcrAm\OfferType; $matches = $client->searchClassifier('bread', OfferType::Product, Language::English); foreach ($matches as $match) { echo "{$match->code} — {$match->title}\n"; }
registerSale(RegisterSaleInput $input): RegisterSaleResponse
Fiscalizes a sale. See Quick start above for the basic shape. A more elaborate example with a business buyer, an inline new offer, and an emailed receipt:
use BlobSolutions\VcrAm\Input\Buyer; use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\Department; use BlobSolutions\VcrAm\Input\Offer; use BlobSolutions\VcrAm\Input\OfferTitle; use BlobSolutions\VcrAm\Input\RegisterSaleInput; use BlobSolutions\VcrAm\Input\SaleAmount; use BlobSolutions\VcrAm\Input\SaleItem; use BlobSolutions\VcrAm\Input\SendReceiptToBuyer; use BlobSolutions\VcrAm\Language; use BlobSolutions\VcrAm\LocalizationStrategy; use BlobSolutions\VcrAm\OfferType; use BlobSolutions\VcrAm\Unit; $response = $client->registerSale(new RegisterSaleInput( cashier: CashierId::byInternalId(42), items: [ new SaleItem( offer: Offer::createNew( externalId: 'consult-1h', title: OfferTitle::localized( content: ['hy' => 'Խորհրդատվություն, 1 ժամ', 'en' => 'Consultation, 1 hour'], localizationStrategy: LocalizationStrategy::Translation, ), type: OfferType::Service, classifierCode: '70.22.11', defaultMeasureUnit: Unit::Hour, defaultDepartment: new Department(2), ), quantity: '1', price: '50000', unit: Unit::Hour, ), ], amount: new SaleAmount(nonCash: '50000'), buyer: Buyer::businessEntity( '01234567', new SendReceiptToBuyer('billing@example.am', Language::Armenian), ), ));
Payment: amount or autoSettle. A sale settles by exactly one of these (the constructor enforces it):
amount: new SaleAmount(...)— explicit AMD per tender, as above.autoSettle: new AutoSettle(AutoSettleTender::Cash)— the VCR computes the AMD cart total and charges the whole of it to that one tender. Zero-tap, and the only sane option for a foreign-currency sale (you can't know the AMD total up front). Prefer theRegisterSaleInput::withAmount()/withAutoSettle()named constructors for a clear call site.
Foreign-currency sales (HO-234-N). Set currency on each item and price that item in the foreign currency; the VCR converts every line to AMD server-side at the previous-business-day CBA rate. The fiscal receipt is always AMD. All foreign-priced items in one sale must share the same currency, and mixing AMD with foreign lines is rejected. Preview the rate with getExchangeRate().
use BlobSolutions\VcrAm\AutoSettleTender; use BlobSolutions\VcrAm\Input\AutoSettle; use BlobSolutions\VcrAm\Input\Buyer; use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\Offer; use BlobSolutions\VcrAm\Input\RegisterSaleInput; use BlobSolutions\VcrAm\Input\SaleItem; use BlobSolutions\VcrAm\Unit; $response = $client->registerSale(RegisterSaleInput::withAutoSettle( cashier: CashierId::byInternalId(42), items: [ new SaleItem( offer: Offer::existing('sku-usd'), quantity: '1', price: '10', // priced in USD unit: Unit::Piece, currency: 'USD', // VCR converts to AMD at the CBA rate ), ], autoSettle: new AutoSettle(AutoSettleTender::Cash), buyer: Buyer::individual(), ));
getSale(int $saleId): SaleDetail
Reads back a previously-registered sale, including all items, refunds, and the cashier snapshot.
$sale = $client->getSale(4711); echo "Sale #{$sale->id} — total cash {$sale->cashAmount}\n"; foreach ($sale->items as $item) { echo " - srcId={$item->srcId} qty={$item->quantity} @ {$item->price}\n"; } foreach ($sale->refunds as $refund) { echo " refund: cash={$refund->cashAmount}\n"; }
registerSaleRefund(RegisterSaleRefundInput $input): RegisterSaleRefundResponse
Refunds a previously-registered sale — full or partial.
Full refund (omit items):
use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\RegisterSaleRefundInput; use BlobSolutions\VcrAm\RefundReason; $response = $client->registerSaleRefund(new RegisterSaleRefundInput( cashier: CashierId::byDeskId('desk-1'), saleId: 4711, reason: RefundReason::CustomerRequest, ));
Partial refund by srcId of a specific sale item:
use BlobSolutions\VcrAm\Input\RefundAmount; use BlobSolutions\VcrAm\Input\RefundItemInput; $response = $client->registerSaleRefund(new RegisterSaleRefundInput( cashier: CashierId::byDeskId('desk-1'), saleId: 4711, reason: RefundReason::DefectiveGoods, reasonNote: 'Mouldy crust on one loaf', refundAmounts: new RefundAmount(cash: '750'), items: [ new RefundItemInput(srcId: 999_001, quantity: '1'), ], ));
registerPrepayment(RegisterPrepaymentInput $input): RegisterPrepaymentResponse
Registers an advance payment from a buyer that will be redeemed against a future sale. Has no items — a prepayment is a single sum, not a basket.
use BlobSolutions\VcrAm\Input\Buyer; use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\PrepaymentAmount; use BlobSolutions\VcrAm\Input\RegisterPrepaymentInput; $response = $client->registerPrepayment(new RegisterPrepaymentInput( cashier: CashierId::byDeskId('desk-1'), amount: new PrepaymentAmount(nonCash: '20000'), buyer: Buyer::individual(), )); echo "Prepayment #{$response->prepaymentId}, fiscal={$response->fiscal}\n";
crn and fiscal may be null if SRC fiscal issuance is pending — the prepayment is recorded either way and the SRC handshake retries asynchronously.
getPrepayment(int $prepaymentId): PrepaymentDetail
Reads back a registered prepayment, including its (at most one) refund.
$prepayment = $client->getPrepayment(9001); echo "Prepayment #{$prepayment->id} — cash={$prepayment->cashAmount}\n"; if ($prepayment->refund !== null) { echo " refunded: cash={$prepayment->refund->cashAmount}\n"; }
registerPrepaymentRefund(RegisterPrepaymentRefundInput $input): RegisterPrepaymentRefundResponse
Refunds a prepayment in full. There is no partial-refund variant — a prepayment is indivisible.
use BlobSolutions\VcrAm\Input\CashierId; use BlobSolutions\VcrAm\Input\RegisterPrepaymentRefundInput; use BlobSolutions\VcrAm\RefundReason; $response = $client->registerPrepaymentRefund(new RegisterPrepaymentRefundInput( cashier: CashierId::byDeskId('desk-1'), prepaymentId: 9001, reason: RefundReason::CustomerRequest, ));
getExchangeRate(string $currency): ExchangeRate
Previews the AMD conversion rate the VCR would apply to a foreign-currency sale registered now — the CBA mid-market rate published on the previous business day (HO-234-N). Read-only; it fiscalizes nothing. $currency is a 3-letter ISO 4217 code (case-insensitive); AMD is rejected server-side.
$rate = $client->getExchangeRate('USD'); $amdEquivalent = (float) $usdPrice * $rate->ratePerUnit; echo "1 USD = {$rate->ratePerUnit} AMD (rate date {$rate->rateDate})\n";
Error handling
Every call can raise one of three exceptions, all extending the abstract BlobSolutions\VcrAm\Exception\VcrException. Catch the base class to handle any SDK-level failure, or branch on the concrete subclass when you care about the cause:
use BlobSolutions\VcrAm\Exception\VcrApiException; use BlobSolutions\VcrAm\Exception\VcrNetworkException; use BlobSolutions\VcrAm\Exception\VcrValidationException; try { $response = $client->registerSale($input); } catch (VcrApiException $e) { // Server returned a non-2xx HTTP response. The API's error envelope is // `{ error, issues?, requestId?, pending? }` and all of it is surfaced: // $e->statusCode — HTTP status (int) // $e->apiErrorMessage — the envelope's `error` (string|null) // $e->issues — list<ApiErrorIssue>, present on validation failures // $e->requestId — correlation id on unexpected 5xx (string|null) // $e->pending — PendingResource|null, see below // $e->rawBody — full response body, in case envelope parsing failed // $e->request — PSR-7 request (X-API-Key header redacted) // $e->response — PSR-7 response // A 502 does NOT always mean nothing happened. When the tax service was // merely unreachable, VCR saved the document and queued it for automatic // resubmission — resending would fiscalize a SECOND receipt, and a fiscal // receipt cannot be deleted, only refunded. if ($e->pending !== null) { $queue->recordPending($e->pending->type, $e->pending->id); return; // nothing was lost; read it back later instead of resending } foreach ($e->issues as $issue) { $errors[$issue->pointer()] = $issue->message; // e.g. 'items.0.price' } throw $e; } catch (VcrNetworkException $e) { // DNS / TLS / TCP / timeout failure before any HTTP response. // $e->getPrevious() is the underlying PSR-18 ClientExceptionInterface. // $e->request — PSR-7 request (X-API-Key redacted) // Safe to retry after a backoff, but be careful with non-idempotent endpoints. throw $e; } catch (VcrValidationException $e) { // Server returned 2xx but the body did not match the expected schema. // Indicates a server-side wire-format change or upstream proxy corruption. // $e->detail — Valinor mapping error or 'response body is not valid JSON: …' // $e->rawBody — full response body // This is never a caller-side bug. File an issue. throw $e; }
The X-API-Key header is stripped from the request preserved on every exception. APMs that auto-serialize exception state (Sentry, Bugsnag, Laravel's verbose handler) will not surface the API key in their breadcrumbs.
Configuration
use BlobSolutions\VcrAm\VcrClient; $client = new VcrClient( apiKey: $apiKey, baseUrl: VcrClient::DEFAULT_BASE_URL, // 'https://vcr.am/api/v1' httpClient: $myPsr18Client, // optional — falls back to discovery requestFactory: $myPsr17Factory, // optional — falls back to discovery streamFactory: $myPsr17Factory, // optional — falls back to discovery logger: $myPsr3Logger, // optional — defaults to NullLogger integration: 'my-plugin/1.2.3', // optional — see below );
Naming your integration
If this SDK ships inside something with a name of its own — a plugin, an app, a hosted product — say so. The token is appended to the SDK's own, and it is what lets a support question about your integration be answered from the server's request log:
$client = new VcrClient( apiKey: $apiKey, integration: sprintf('my-plugin/%s (WordPress/%s; PHP/%s)', $pluginVersion, $wpVersion, PHP_VERSION), ); // User-Agent: vcr-am-sdk-php/0.10.0 (+https://github.com/blob-am/vcr-am-sdk-php) my-plugin/1.2.3 (WordPress/7.1; PHP/8.4.2)
Printable ASCII, 1-200 characters, validated when the client is constructed: the value ends up in a header, and a version string assembled from platform metadata is not something to trust blindly.
PSR-3 logger
The SDK emits structured log lines for outbound requests, network failures, and 4xx/5xx responses. Pass any PSR-3 logger to capture them:
use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('vcr-am'); $logger->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG)); $client = new VcrClient(apiKey: $apiKey, logger: $logger);
Channels emitted:
| Level | Message | Context |
|---|---|---|
debug |
VCR.AM request |
method, url |
warning |
VCR.AM network failure |
method, url, error |
warning |
VCR.AM API error |
method, url, status, errorCode, errorMessage, rawBodyPreview (≤500 bytes) |
The X-API-Key header is never logged.
PSR-18 HTTP client
The SDK auto-discovers a PSR-18 client via php-http/discovery (the bundled Guzzle 7 by default). To override — most commonly, to set timeouts or attach middleware — construct your own and inject it:
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\RequestOptions; $stack = HandlerStack::create(); $stack->push(Middleware::retry( fn ($retries, $req, $res, $err) => $retries < 2 && $err !== null, fn ($retries) => 1000 * (2 ** $retries), )); $http = new Guzzle([ RequestOptions::CONNECT_TIMEOUT => 5, RequestOptions::TIMEOUT => 30, 'handler' => $stack, ]); $client = new VcrClient(apiKey: $apiKey, httpClient: $http);
Any PSR-18 client works — Symfony's symfony/http-client, Laravel's Illuminate\Http\Client\Factory (with a small adapter), php-http/curl-client, etc.
PSR-17 factories
The same php-http/discovery mechanism finds a PSR-17 request/stream factory (by default nyholm/psr7's Psr17Factory). Override only if you need to keep a single PSR-17 implementation across your application — the SDK does not care which one it is, as long as it conforms to the standard.
Timeouts
PSR-18 has no portable per-request timeout primitive, so the SDK does not expose one. Configure the timeout on the HTTP client you pass in (or on the auto-discovered Guzzle instance via the bundled guzzlehttp/guzzle dependency):
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\RequestOptions; $http = new Guzzle([ RequestOptions::CONNECT_TIMEOUT => 5, // seconds RequestOptions::TIMEOUT => 30, // seconds, total ]); $client = new VcrClient(apiKey: $apiKey, httpClient: $http);
A bounded timeout is strongly recommended for fiscalization endpoints — an indefinite hang on a stalled SRC backend will block the calling thread.
Monetary precision
Decimal fields on request types (SaleAmount::$cash, SaleItem::$price, etc.) are typed as string to preserve full precision over the wire. Only digits and at most one decimal point are accepted — no sign, no scientific notation. Constructor-time validation enforces the shape.
Decimal fields on response types (SaleDetail::$cashAmount, Receipt::$total, etc.) are typed as PHP float because the API serialises them as JSON numbers (Prisma Float columns). This mirrors the TypeScript SDK's z.number() shape. Do not use these floats for reconciliation arithmetic — re-fetch the receipt or compute against your own ledger of decimal-strings sent in. Equality comparisons on these floats are unsafe.
Idempotency and retries
A fiscal receipt cannot be corrected, only refunded and reissued, so a retry that files a second one is the most expensive mistake this SDK can help you make. Pass an idempotency key and a repeat of the same call returns the original receipt instead:
$sale = $client->registerSale($input, $order->fiscalizationKey);
The key is the second argument on registerSale(), registerSaleRefund(), registerPrepayment() and registerPrepaymentRefund(). It travels as the Idempotency-Key header, stays replayable for 30 days, and is scoped per endpoint and per register — so the same value on /sales and on /sales/refund is two independent keys.
Generate it once per intended fiscal operation and persist it with the order before the first call, then reuse it verbatim for every retry. A fresh value per attempt protects nothing, and the order number on its own is too coarse when one order can produce several fiscal documents. For a stateless webhook handler that may be delivered twice, derive it instead:
$key = Uuid::uuid5(Uuid::fromString(self::NAMESPACE), "{$order->id}:sale")->toString();
Both deliveries then compute the same key and the second one replays. Do not derive it from the payment provider's event id — a different event about the same order would produce a different key and fiscalize again.
Server responses to a replayed request are byte-identical to the original and carry Idempotent-Replay: true. Reusing a key with a different body answers 422; reusing one while the first request is still in flight answers 409, which means wait and retry with the same key rather than minting a new one. A body the server rejected on validation spends no key — the request never reached the operation, so correct the field and retry with the same key.
Without a key, a retried request files a second fiscal document. The SDK does not retry automatically for that reason. If you add retries, attach a Guzzle (or other PSR-18) middleware that retries only on VcrNetworkException-class failures (DNS/TLS/timeout) and never on a VcrApiException — the latter means the server has already seen and rejected your request — and always send the key.
Compatibility
| SDK version | PHP versions tested |
|---|---|
^0.x |
8.2, 8.3, 8.4, 8.5 |
Development
composer install composer check # format check + phpstan + tests composer format # apply Pint fixes
CI enforces pint --test, PHPStan max + strict + deprecation rules + phpunit extension, and a 100% line-coverage gate on PHP 8.5.
License
ISC © Alex Kraiz, Blob Solutions