dominaite / merchant-sdk
Server-side PHP client for the Dominaite merchant API: create hosted checkout sessions from your own backend.
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.5.0
- v0.4.0
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-docs/v0.5.0-access-notes
- dev-feat/v0.5.0
- dev-feat/card-fields-integration
- dev-chore/composer-test-wire-contract
- dev-chore/release-0.3.1
- dev-docs/changelog-0.3.0
- dev-feat/contract-refresh
- dev-feat/checkout-hardening
- dev-feat/recurring-stored-payment-methods
This package is auto-updated.
Last update: 2026-10-11 19:10:56 UTC
README
Server-side PHP client for the Dominaite merchant API. One call from your backend opens a hosted checkout session; a two-line script tag renders the payment widget on your page. Card details go straight from your customer's browser into the payment widget - they never touch your server, which keeps your PCI scope minimal (SAQ A).
Works on plain PHP 7.4+ with curl. No framework required.
The integration is three moving parts: create a session from your backend, render the widget, and receive a webhook when the payment lands. Webhooks are how you learn the outcome; polling is the fallback for when you have not set one up yet.
Install
composer require dominaite/merchant-sdk
PHP 7.4 or newer with ext-curl, ext-json and ext-mbstring. No other dependencies.
Credentials
You get two values from Dominaite (shown once - store them like passwords):
dmk_...- your API key id. Identifies you; not secret by itself.dms_...- your API secret. Server-side only: environment variable or config outside the web root. Never in a browser, never in git, never in logs.
Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock on NTP - signatures older than 5 minutes are rejected.
The third constructor argument overrides the base URL for non-production environments. It
has to be https://: the key id and the signature travel in headers, and a captured
signed request stays replayable for the whole 5 minute clock window. http:// is accepted
only for localhost, 127.0.0.1 and ::1, so a local mock still works. Anything else
throws InvalidArgumentException at construction rather than on your first live payment.
Do not dump the client
The client holds your secret in memory. Do not var_dump(), print_r(), var_export(),
(array)-cast or serialize() a client as a way to inspect it, and do not let one reach a
log line or an error tracker.
var_dump(), print_r() and serialize() are redacted for you - the client implements
__debugInfo() and __serialize(), which Symfony VarDumper, Ignition and Whoops honour too,
so a client caught in an exception page does not print your secret. json_encode() returns
{} because the properties are private.
var_export(), an (array) cast and Reflection are NOT redacted. They honour no hook and
print the secret in full. There is no way for the SDK to intercept them, so this one is on you.
One more thing worth setting in production php.ini:
zend.exception_ignore_args = 1
Without it, a stack trace records the arguments each frame was called with, so an exception
thrown anywhere below new DominaiteClient(...) carries your plaintext secret into whatever
renders or ships that trace.
Scopes: a payments key and a reporting key
Each key holds scopes. The scopes decide which routes the key can call:
payments: sessions and their status, charges and revokes, refunds, plans and agreements. A key issued before scopes existed holds this scope.transactions:read:listTransactions().webhooks:manage:createWebhookEndpoint(),listWebhookEndpoints()anddeleteWebhookEndpoint().
A payments key never holds the other two scopes. A payments key always gets HTTP 403
INSUFFICIENT_SCOPE on the methods of those scopes. Use a separate reporting key for them, in
a second client. If your dashboard has no reporting key section, ask us (Dominaite support)
for the key. A reporting key covers your whole account. We cannot bind it to a storefront or
a location. The scopes of a key never change: for other scopes you need a new key. Every key
can call ping() and getIntegrationContract().
A key without the scope of a route gets HTTP 403 INSUFFICIENT_SCOPE, and the SDK throws
AuthenticationException. Its message says that the scope is the problem. The signature was
valid, so your key id, secret and clock are not the cause. Only a 401 points at those three.
What we switch on for your account
These features work only after we switch them on for your account: ask us (Dominaite support).
- Stored-card charges:
chargePaymentMethod(). Without this switch, the API answers HTTP 503PAYMENT_METHOD_CHARGES_DISABLED. - Subscription sessions. Without this switch, a session with
agreementIdanswerssuccess=falsewithSUBSCRIPTIONS_NOT_ENABLED. - Scheduled charges. Without this switch, the platform runs no scheduled charge and no retry, even when the subscription session works.
- Verify-only: a session with
'verifyOnly' => true. Without this switch, the API answers HTTP 403VERIFY_ONLY_NOT_ENABLED. - Card fields: enabled per merchant on request (see "Card fields").
The webhook endpoint methods and listTransactions() need a second key, the reporting key (see
"Scopes" above). A payments key always gets HTTP 403 INSUFFICIENT_SCOPE on these methods.
Ping before your first session
One signed GET that creates nothing, so anything that fails here is your credentials, your signing or your clock - not the payment:
<?php require __DIR__ . '/vendor/autoload.php'; use Dominaite\DominaiteClient; $client = new DominaiteClient(getenv('DOMINAITE_KEY_ID'), getenv('DOMINAITE_SECRET')); print_r($client->ping()); // ['pong' => true, 'merchantId' => '...', 'serverTime' => '...', 'clockSkewSeconds' => 0]
Watch clockSkewSeconds: the gateway rejects requests once it passes 300, so a number that
keeps growing is your cue to fix NTP before payments start failing.
Read the integration contract
getIntegrationContract() returns the public integration contract of the API: the routes,
the fields, the status values and the error codes as the API uses them now. Every key can
read it, and it creates nothing.
$contract = $client->getIntegrationContract(); echo $contract['version'];
The SDK itself never reads the contract at runtime. Use it to check your own integration, for example in a test that fails when a value you depend on changes.
Create a session (your create_session.php)
<?php require __DIR__ . '/vendor/autoload.php'; use Dominaite\DominaiteClient; use Dominaite\Exception\CheckoutRefusedException; use Dominaite\Exception\RateLimitException; use Dominaite\Exception\StorefrontException; use Dominaite\Exception\TransportException; $client = new DominaiteClient(getenv('DOMINAITE_KEY_ID'), getenv('DOMINAITE_SECRET')); $amount = 2500; // minor units: 2500 = 25.00 EUR try { $session = $client->createCheckoutSession([ 'amount' => $amount, 'currency' => 'EUR', 'orderReference' => 'order-1042', // your own order id, shows up in your dashboard // Required. Same order + same amount = same key, so a reload or a retry gets the // session that is already open instead of a second payment. 'idempotencyKey' => DominaiteClient::orderIdempotencyKey('checkout', 'order-1042', $amount, 'EUR'), 'customer' => [ // Pass everything you already know - prefilled fields are hidden from the // payer, so the checkout form stays short. 'firstName' => 'Ana', 'lastName' => 'Kirova', 'email' => 'ana@example.com', ], 'language' => 'bg', // widget UI language 'theme' => 'dark', ]); } catch (CheckoutRefusedException $e) { // Machine-readable: $e->getErrorCode() - see the exception docblock for the codes. http_response_code(409); exit('Payment unavailable: ' . $e->getErrorCode()); } catch (StorefrontException $e) { // This website cannot take payments yet (or any more). Not retryable - see below. error_log('Dominaite storefront refusal: ' . $e->getErrorCode()); http_response_code(503); exit('Online payments are not available on this site yet'); } catch (RateLimitException $e) { // You are over the rate limit. Nothing is retried for you - back off first. http_response_code(503); header('Retry-After: ' . ($e->getRetryAfterSeconds() ?? 5)); exit('Payment temporarily unavailable'); } catch (TransportException $e) { // Network blip or a 5xx - retry with the same params, so the same key, never a fresh one. http_response_code(503); exit('Payment temporarily unavailable'); } // Store $session['transactionId'] against your order, then render the widget: ?> <div id="checkout"></div> <script src="https://bp-checkout.dominaite.com/v2/launcher" data-cashier-key="<?= htmlspecialchars($session['cashierKey']) ?>" data-cashier-token="<?= htmlspecialchars($session['cashierToken']) ?>"></script>
orderReference is limited to 100 characters, counted as characters and not as bytes - a
100 character Cyrillic or Greek reference is 200 bytes and is fine. Emoji and rarer CJK
characters count double on the server, so stay a couple of characters clear of the limit if
your references contain them; the server has the final say either way.
Storefront errors
If your merchant account has more than one website, each one is a storefront, and a
session is refused when its storefront cannot take payments. These come back as HTTP 4xx
and the SDK raises StorefrontException (an ApiException, so older catch blocks still see
it). Branch on getErrorCode(), using the constants on DominaiteClient:
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
STOREFRONT_NOT_WHITELISTED |
409 | The site's domain is not yet whitelisted with the payment provider. | Nothing to retry. Ask Dominaite to finish onboarding the domain. |
STOREFRONT_INACTIVE |
409 | The storefront was deactivated or deleted. | Nothing to retry. Check the location in your dashboard. |
STOREFRONT_MISMATCH |
400 | The API key belongs to another storefront than the request names. | Use the key issued for this site. |
Replaying a key that was first used for a different storefront answers STOREFRONT_MISMATCH
as an HTTP 200 refusal, so that one case arrives as CheckoutRefusedException.
That's the checkout half: the session call above, the script tag, and your domain bound to your checkout by Dominaite during onboarding. The other half is the webhook that tells you the payment happened.
Webhooks
Register an endpoint in the dashboard (Developers, Webhooks): an HTTPS URL, the events you
want, and you get a signing secret whsec_... shown exactly once. Store it like the API
secret. Regenerating it kills the old one. The dashboard page needs no API key.
Manage endpoints from your server
You can also manage endpoints through the API. These calls need a reporting key with the
webhooks:manage scope (see "Scopes" above).
$reporting = new DominaiteClient(getenv('DOMINAITE_REPORTING_KEY_ID'), getenv('DOMINAITE_REPORTING_SECRET')); $endpoint = $reporting->createWebhookEndpoint([ 'url' => 'https://shop.example/webhook.php', 'events' => ['payment.succeeded', 'payment.failed'], 'idempotencyKey' => 'endpoint-shop-main', ]); $vault->store('whsec', $endpoint['secret']); // in this first answer only foreach ($reporting->listWebhookEndpoints() as $item) { // id, url, events, status (active or inactive), createdAt. Never the secret. } $reporting->deleteWebhookEndpoint($endpoint['id']);
- The secret is in the first answer only. On a replay of the same key
secretisnull, and the list never has it. If you lose it, delete the endpoint and create it again. - After a timeout or a 503, retry with the same key. The retry can answer a new id and a new secret, and one key never holds two endpoints.
- The SDK does not check event types. The API refuses an unknown type with
VALIDATION_ERROR. - A key used again more than a day later, while its endpoint still exists, answers 409
WEBHOOK_ALREADY_REGISTERED.$e->getResult()['error']['params']['webhookId']is that endpoint. Use a fresh key for a new endpoint. - Deleting an unknown or deleted endpoint answers 404
WEBHOOK_NOT_FOUND. When the answer to a delete was lost, treat that 404 as done.
Receiving a delivery (your webhook.php)
<?php require __DIR__ . '/vendor/autoload.php'; use Dominaite\DominaiteClient; // The RAW body, before any parsing. Do not use $_POST and do not re-encode: // the signature covers these exact bytes. $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? ''; if (!DominaiteClient::verifyWebhook($payload, $signature, getenv('DOMINAITE_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($payload, true); // Answer immediately, then do the work. Anything slow here eats into the delivery // timeout and earns you a retry you did not need. http_response_code(200); if (!already_handled($event['id'])) { // dedupe on the delivery id queue_fulfilment($event); // your own job queue mark_handled($event['id']); }
verifyWebhook($payload, $signatureHeader, $secret, $toleranceSeconds = 300, $now = null)
returns true or false. It returns false for everything the sender controls: a bad
signature, a body that changed by one byte, a stale timestamp, a missing or garbled header.
It throws InvalidArgumentException only for your own mistakes, an empty secret or a
negative tolerance. Verify before you parse, always.
The header is X-Webhook-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256
over "{t}.{raw body}" keyed with your endpoint secret. The timestamp is inside the MAC and
checked against a 300 second window, so a captured delivery cannot be replayed later.
What arrives
{
"id": "delivery id, your dedupe key",
"type": "payment.succeeded",
"apiVersion": "2026-09-25",
"createdAt": "2026-08-20T14:00:00Z",
"data": {
"transactionId": "...",
"orderReference": "order-123",
"orderId": "dom_9a8b7c6d5e4f",
"pspReference": "psp_8f3a21c4",
"description": "Pro plan",
"status": "succeeded",
"previousStatus": "pending",
"kind": "sale",
"amount": 8440,
"grossAmount": 8701,
"surchargeAmount": 261,
"currency": "EUR",
"paymentMethod": "card",
"walletType": null,
"paymentMethodBrand": "visa",
"paymentMethodLast4": "4242",
"originalTransactionId": null,
"idempotencyKey": "order-123"
}
}
Flat envelope, no success wrapper to branch on. Amounts are minor units: amount is what
you get paid, grossAmount is what moved on the card, surchargeAmount is the difference
when a surcharge applies.
orderReference is your own order reference from create session and the field to match
events to your orders on. It is null when the payment did not start through the API or the
session had none, and refund and cancel events carry the original payment's value. orderId is
the hosted checkout order id, the same as on getStatus(), null on refunds, cancellations and
payments taken outside hosted checkout. description is what you sent on create session, null
when you sent none and on refund and cancel events. paymentMethodBrand (lower-cased, visa,
mastercard, ...) and paymentMethodLast4 are set once a card payment was attempted and null
otherwise. idempotencyKey is the key you sent on create session, null on refund and dispute
events. originalTransactionId points at the earlier transaction: the refunded payment on
payment.refunded, the authorization on the payment.succeeded of a capture. The event is a
plain array, so read any of these with ?? null: an event recorded before a field existed
arrives without it.
apiVersion is the date of the payload shape the event was rendered in, currently
2026-09-25. Fields are only ever added under a version, never renamed or removed, so
ignore fields you do not recognise. A retry resends the bytes of the first attempt, so an
event recorded before the field existed arrives without it: read it as
$event['apiVersion'] ?? null. The signature covers the raw body whatever it carries,
so nothing changes in how you verify.
Events: payment.succeeded, payment.failed, payment.requires_capture,
payment.cancelled, payment.abandoned, payment.refunded, payment.disputed.
payment.succeeded is the only one that means money in hand. In-flight states (pending,
processing) are not webhooked, so drive that part of your UX from the session status.
Processor reference on payment events
payment.* events carry data.pspReference, the payment processor's reference for the
transaction, and getStatus() returns the same value as pspReference. It is null until
the reference is known, and a sale settled by reconciliation without a processor webhook can
keep it null. Refund and cancel events carry the original sale's reference. charge.*
events do not have it. Older gateways leave the key out, so read it as
$event['data']['pspReference'] ?? null.
Saved card on payment events
payment.succeeded and payment.requires_capture carry data.storedPaymentMethod when the
payment saved a card (saveCard): the same object getStatus() returns as
storedPaymentMethod, with the same fields and values. On every other payment.* event it
is null.
"storedPaymentMethod": { "id": "pm_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "brand": "visa", "last4": "4242", "expiryMonth": 12, "expiryYear": 2030, "status": "active", "retiredReason": null }
status is active, revoked, expired or retired; retiredReason is hard_decline,
chargeback, source_sale_reversed or null. Webhook nulls arrive as an explicit null,
but events recorded before the field existed do not carry it at all, so read it as
$event['data']['storedPaymentMethod'] ?? null.
It can also be null when a card WAS saved: on a server-to-server sale approved synchronously, on a sale whose outcome the platform confirmed later, and whenever the card was stored after the approval was announced.
getStatus()is the source of truth. On asaveCardpayment whose event has no card, read the status to pick it up.
Agreement and charge events: order by sequence
agreement.activated, agreement.past_due, agreement.cancelled, charge.succeeded,
charge.failed and charge.retrying ride the same signed envelope. Their data carries an
integer sequence that counts the announced changes of one object. It only ever rises, and
a redelivery carries the same number.
Deliveries can arrive out of order. Keep the highest sequence you have processed per object and discard any event whose sequence is not higher; when you need current state, read the object by id. createdAt can repeat across events, so order by sequence, not createdAt. A sequence of 0 only comes from events recorded before the counter existed; treat it as older than any positive number.
The object is:
agreement.*: the agreement,data.id.charge.*the platform placed for an agreement: the agreement period,data.agreementIdplusdata.periodNumber, so the attempts of one period compare with each other.charge.*for a one-offchargePaymentMethod():data.chargeId(agreementIdis null).
$data = $event['data']; if (strpos($event['type'], 'agreement.') === 0) { $object = 'agreement:' . $data['id']; } elseif ($data['agreementId'] !== null) { $object = 'period:' . $data['agreementId'] . ':' . $data['periodNumber']; } else { $object = 'charge:' . $data['chargeId']; } $sequence = $data['sequence'] ?? 0; // absent on events recorded before the counter $highest = $store->highestSequence($object); // null when you have not seen this object yet if ($highest !== null && $sequence <= $highest) { return; // stale or repeated: newer state is already applied } apply_event($event); $store->saveHighestSequence($object, $sequence);
Keep deduping on the envelope id as well: that catches the same delivery twice, the
sequence catches an older delivery arriving after a newer one. payment.* events carry no
sequence.
Delivery guarantees
- At least once. The same delivery can arrive twice, so dedupe on
idand make your handler idempotent. - Respond 2xx fast and queue the work. Never fulfil the order inline in the request.
- Failed attempts retry up to your configured count (3 by default, 10 max), spaced 1m, 5m, 30m, 2h, 12h.
- An endpoint that fails its initial attempt and every retry in a row gets disabled automatically, and re-enables itself on the next successful delivery. An endpoint you disable by hand stays disabled.
- Up to 25 active endpoints per merchant.
Reconcile anyway
Webhooks complement a reconciliation sweep, they do not replace it. Keep a job that walks
your open orders and calls getStatus() on anything past its expected settle time. There
are windows where a delivery never lands: a chain parked on a disabled endpoint, or an
outage on our side between the payment and the publish. The sweep is what closes them, and
it is not optional if you care about your books.
Amounts are minor units
amount is always an integer in the currency's minor unit, which depends on how many
decimals the gateway counts for that currency. EUR has two, so 2500 is 25.00 EUR. JPY has
none, so 1000 is 1000 JPY, not 10.00. HUF has none either: the gateway counts whole
forints, although ISO 4217 lists two, so 5000 is 5000 HUF. KWD has three, so 1250 is
1.250 KWD. The amount is locked server-side - what you pass here is what gets charged;
nothing in the browser can change it.
If your shop stores prices as decimal strings, convert with toMinorUnits() rather than
multiplying a float ((int) (0.3 * 100) is 29):
DominaiteClient::toMinorUnits('0.30', 'EUR'); // 30 DominaiteClient::toMinorUnits('1000', 'JPY'); // 1000 DominaiteClient::toMinorUnits('5000', 'HUF'); // 5000, whole forints DominaiteClient::toMinorUnits('1.250', 'KWD'); // 1250 DominaiteClient::toMinorUnits('25.000', 'EUR'); // throws: EUR has 2 decimal places
It takes a string, parses it without floats, and throws InvalidArgumentException for more
decimals than the currency has (zeros included, so "25.000" EUR is refused), for anything
that is not plain digits with an optional dot, and for a currency it does not know. Known
today: EUR, USD, GBP, CAD, AUD, CHF, BGN, RON, PLN, CZK, SEK, DKK, NOK (two decimals), JPY
and HUF (none), BHD and KWD (three). ISK, KRW, OMR, JOD and TND throw as not supported,
because ISO 4217 and the gateway disagree on their decimals and either guess could be off
by 10x or 100x. minorUnitExponent($currency) returns the exponent on its own.
Retries and double-charges
idempotencyKey is required on createCheckoutSession(), chargePaymentMethod() and
createRefund(). Leave it out and the SDK throws InvalidArgumentException before anything is sent. It used to
generate a random key for you; that is gone, because a random key per request turns every
page reload, back button or retried timeout into a second payment for the same order.
Derive the key from the order instead:
$key = DominaiteClient::orderIdempotencyKey('checkout', $order->id, $amountMinor, $currency); // "checkout-1042-2500-EUR"
The shape is {scope}-{orderId}-{amountMinor}-{CURRENCY}. The same order at the same amount
always gives the same key, so a reload or a retry replays the session that is already open
(same transactionId, same cashierKey and cashierToken) instead of opening a new one.
Change the amount or the currency and you get a new key, which is what you want: the gateway
refuses a known key sent with a different amount (IDEMPOTENCY_KEY_REUSED). scope keeps
two flows for one order apart (checkout and renewal, say); keep it a fixed string per
flow. The result follows the usual key rules (1 to 100 visible ASCII characters, 0x21-0x7E,
so no spaces), and
the helper throws if it would not.
On a timeout, retry with the same params and so the same key:
$params['idempotencyKey'] = DominaiteClient::orderIdempotencyKey('checkout', 'order-1042', 2500, 'EUR'); $session = null; for ($attempt = 1; $attempt <= 3 && $session === null; $attempt++) { try { $session = $client->createCheckoutSession($params); } catch (TransportException $e) { if ($attempt === 3) { throw $e; } sleep($attempt); } }
getLastIdempotencyKey() still reads back the key of the last attempt that went out, for a
generic catch block that no longer has the params.
A replay only hands back the original session while that session is open and unexpired. If
the first attempt already completed, failed, or was sent with a different amount, the retry
answers HTTP 200 with success=false and a replay code (DUPLICATE_REQUEST,
ALREADY_PROCESSED, PRIOR_ATTEMPT_FAILED or IDEMPOTENCY_KEY_REUSED), which the SDK raises
as a CheckoutRefusedException carrying the transaction id to reconcile against; see
"Recovering from a replay refusal" below. Treat it as "go look up what the first attempt did",
not as an error to show the payer.
Rate limits
60 requests per minute per API key, 120 per minute per source IP. The per-IP bucket is the one that surprises people: several keys behind one egress address share it, so a key well under 60/min can still be limited.
Over the limit the API answers HTTP 429 and the SDK raises RateLimitException. It is not
retried for you - a loop against a rate limiter just spends the next window too, and on
createCheckoutSession() retrying is a decision about a payment that belongs in your code.
getRetryAfterSeconds() gives the server's own number of seconds to wait, or null when it
did not send a usable one; treat null as "use your own backoff", not "retry now". When you
do retry, reuse the idempotency key.
RateLimitException extends ApiException, so code written before it existed still catches
a 429. Catch RateLimitException first if you want to branch on it.
getErrorCode() names the limit you hit:
RATE_LIMIT_EXCEEDED: the request limits above. After too many failed authentication attempts from one IP, the API also blocks that IP for a while with this code. That 429 has noRetry-After.CHARGE_VELOCITY_EXCEEDED(chargePaymentMethod()only): too many charges on the card, or too much money in the currency, in the window.CHARGE_DECLINE_RATE_EXCEEDED(chargePaymentMethod()only): the key collected too many declines, so its charges are locked for a while.
Nothing was charged under any of them. The code is null when the 429 did not come from the
API itself. Treat a code you do not know as a rate limit.
The polling loop in "Fallback: status polling" is the usual way to hit this. Poll one transaction every few seconds, not every transaction every second.
Response size
Responses are read up to 10 MB and no further. A real merchant-API response is a few
kilobytes, so anything near that is a captive portal or an edge serving something that is
not us, and reading it to the end would grow a worker's memory for no reason. An oversized
response surfaces as TransportException - retryable, same as any other transport failure.
Sessions expire
A session is valid for 2 hours. If the payer comes back later, create a new session - and re-POST with the same order-derived idempotency key, not a fresh one: from a few minutes past expiry the same key answers with a fresh session (see "Recovering from a replay refusal").
Card fields
Instead of the hosted widget, you can render card fields inside your own checkout page. Card
fields are enabled per merchant on request: ask Dominaite support to switch them on. Until then a
session with 'integration' => 'fields' is rejected with a 400 (INVALID_SELECTION on
integration).
Pass 'integration' => DominaiteClient::INTEGRATION_FIELDS when you create the session. Leave it
out (or pass INTEGRATION_WIDGET) for the hosted widget. It is part of the idempotency identity,
so a replay of the same key with a different integration is refused with
IDEMPOTENCY_KEY_REUSED.
$session = $client->createCheckoutSession([ 'amount' => 8440, 'currency' => 'EUR', 'orderReference' => 'order-1042', 'integration' => DominaiteClient::INTEGRATION_FIELDS, 'idempotencyKey' => DominaiteClient::orderIdempotencyKey('checkout', 'order-1042', 8440, 'EUR'), ]); // $session['integration'] === 'fields', $session['clientSecret'] is set $checkoutConfig = [ 'transactionId' => $session['transactionId'], 'integration' => $session['integration'], 'cashierKey' => $session['cashierKey'], 'cashierToken' => $session['cashierToken'], 'clientSecret' => $session['clientSecret'], ]; ?> <div id="checkout"></div> <script src="https://pay.dominaite.com/v1/checkout.js"></script> <script> const checkout = Dominaite.checkout(<?= json_encode($checkoutConfig, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>) checkout.on('success', () => { /* show a "thank you, confirming" state */ }) checkout.mount('#checkout') </script>
Hand those five values (transactionId, integration, cashierKey, cashierToken,
clientSecret) to the payer's page and nothing else. clientSecret is what lets the browser
charge this one session: keep it out of your logs. A widget session has no clientSecret key, so
read it with $session['clientSecret'] ?? null.
The page saying "success" is not proof of payment. Mark the order paid only from the
payment.succeeded webhook or a getStatus() read, exactly as with the widget.
Stored payment methods (recurring)
Pass 'saveCard' => true when you create a session and, once that payment is approved, the
gateway keeps the card on file. You never see the card number or the provider token:
getStatus() returns a storedPaymentMethod with an opaque id (pm_ + 32 hex characters),
the brand, the last4 and the expiry, and that id is what you charge and revoke with. Store
it against your customer. (paymentMethod on the same status is something else: the gateway's
string category of how the payer paid, card, wallet and so on.)
saveCard works on every account. chargePaymentMethod() works only after we switch
stored-card charges on for your account. Until then, the API answers HTTP 503
PAYMENT_METHOD_CHARGES_DISABLED. The API also answers this code when we pause charges for a
short time. The code is the same in both cases. If we never switched stored-card charges on
for your account, a retry does not help: ask us (Dominaite support). If they are on, retry
later with the same idempotency key.
use Dominaite\Exception\ChargeException; use Dominaite\Exception\RevokeException; $session = $client->createCheckoutSession([ 'amount' => 2500, 'currency' => 'EUR', 'orderReference' => 'sub-8817-first', 'idempotencyKey' => DominaiteClient::orderIdempotencyKey('checkout', 'sub-8817-first', 2500, 'EUR'), 'saveCard' => true, ]); // ... the payer completes the hosted checkout ... $status = $client->getStatus($session['transactionId']); $stored = $status['storedPaymentMethod'] ?? null; // absent until the payment is approved if ($status['status'] === 'succeeded' && ($stored['status'] ?? null) === 'active') { $db->saveCard($customerId, $stored['id']); // pm_... } // Later, off-session, no payer present: try { $charge = $client->chargePaymentMethod($paymentMethodId, [ 'amount' => 2500, 'currency' => 'EUR', 'orderReference' => 'sub-8817-2026-10', 'description' => 'Monthly plan, October', // Derived from the billing period, never random per attempt. 'idempotencyKey' => DominaiteClient::orderIdempotencyKey('renewal', 'sub-8817-2026-10', 2500, 'EUR'), ]); switch ($charge['status']) { case 'succeeded': break; case 'pending': // Not terminal. Poll getStatus($charge['transactionId']), or wait for the webhook. break; case 'failed': // HTTP 402 from the gateway, but not an exception: branch on the class, log the code. // hard - give up on this card, ask the customer for another one // soft_funds - insufficient funds, retry later (not in a loop) // soft_sca_required - the issuer wants the customer present: send them through a // hosted session with saveCard and charge the new method // soft_other - transient, one retry later is reasonable // null - no class, for example nobody declined: handle it like soft_other handleDecline($charge['declineClass'], $charge['declineCode']); break; case 'cancelled': // An authorization voided before capture; no money moved. break; } } catch (ChargeException $e) { switch ($e->getErrorCode()) { case 'CHARGE_OUTCOME_UNKNOWN': // 502: the provider gave no verdict, the charge MAY have happened. Never retry // under a new key: poll the transaction the gateway attached instead. pollUntilSettled($e->getTransactionId()); break; case 'DUPLICATE_REQUEST': case 'PAYMENT_PROCESSING_UNAVAILABLE': // Nothing was charged; retry later with the SAME idempotency key. break; case 'PAYMENT_METHOD_CHARGES_DISABLED': // 503, nothing was charged. If we never switched stored-card charges on for your // account, a retry does not help: ask us. If they are on, retry later with the // SAME idempotency key. break; case 'PAYMENT_METHOD_NOT_ACTIVE': // Revoked or expired: bring the customer back for a hosted session with saveCard. break; case 'CHARGE_FAILED': // 502, nothing was charged. getCharge() is set when a row exists. break; case 'IDEMPOTENCY_KEY_REUSED': // Same key, different body or method: a bug on your side. break; } } // When the customer removes the card: try { $client->revokePaymentMethod($paymentMethodId); // 204, returns nothing; 204 again if already revoked } catch (RevokeException $e) { if ($e->getErrorCode() === 'MERCHANT_API_UNAVAILABLE') { // 503: nothing changed, retry later. } else { // 502 UPSTREAM_CONTRACT_ERROR: the provider refused for good, nothing changed. Contact support with the id. } }
A charge is signed exactly like a session and carries a required Idempotency-Key, so a
retry after a timeout with the same key never charges the card twice: the gateway replays
its first answer, HTTP status included. getLastIdempotencyKey() reads the key back the same
way it does for a session. The HTTP status is the contract on this route: 201 (or 200 on a replay) returns
the charge, 402 returns the charge too (status failed), and 409, 422,
502 and 503 throw ChargeException with getErrorCode(), getHttpStatus(), the gateway's
message and, when the gateway attached the charge row, getCharge() and getTransactionId().
Only authentication (401/403), an id that is not yours (404, ApiException with
getErrorCode() PAYMENT_METHOD_NOT_FOUND), validation (400, ApiException), rate limiting
(429) and network failures keep their generic exceptions. declineClass and declineCode are
null unless the charge was declined; the gateway omits them on the wire and the SDK reads
absent as null. declineClass can be null, for example when nobody declined the payment.
Handle a null class on a 402 like soft_other.
Revoking signs an empty key and an empty body, like getStatus(). A revoke that fails with
RevokeException changed nothing: MERCHANT_API_UNAVAILABLE (503) is retryable,
UPSTREAM_CONTRACT_ERROR (502) is not. After a revoke the status read keeps the
storedPaymentMethod with status revoked, and a charge against it is refused with
PAYMENT_METHOD_NOT_ACTIVE. An id that is not yours is an ApiException with
getHttpStatus() 404.
The platform can also retire a card on its own: status retired, with retiredReason set to
hard_decline, chargeback or source_sale_reversed
(DominaiteClient::STORED_PAYMENT_METHOD_RETIRED_REASON_VOCABULARY). A retired card is refused
with PAYMENT_METHOD_NOT_ACTIVE too and never becomes active again, so ask the customer to save
a card again. retiredReason is null on every other card.
Save a card without a payment (verify-only)
Send 'verifyOnly' => true to save a card with no payment, and leave amount out or send 0.
The checkout places a small hold to check the card and saves the card. Then the platform
releases the hold. You and the payer are never charged.
$session = $client->createCheckoutSession([ 'currency' => 'EUR', 'orderReference' => 'card-check-' . $customerId, // orderIdempotencyKey() needs a positive amount, so build this key yourself. 'idempotencyKey' => 'verify-' . $customerId, 'verifyOnly' => true, ]);
- Verify-only is off by default. We switch it on for your account. Before that, the API
answers HTTP 403
VERIFY_ONLY_NOT_ENABLEDand the SDK throwsAuthenticationException. - The API refuses
'saveCard' => false, the card fields integration andagreementIdon a verify-only session. - The status read has
verifyOnlyset totrue. Itsstatusstays the raw status:requires_capture, thencancelledwhen the hold is released. A non-nullstoredPaymentMethodtells you that the card is verified. - A verify-only session fires
verification.succeededorverification.failed, never apayment.*event.
Subscriptions: plans and agreements
A plan is a price that repeats: an amount, a currency and an interval. An agreement puts one customer on one plan. Every payments key can create plans and agreements.
Subscription sessions and scheduled charges are two separate switches: ask us (Dominaite
support) for both. Until we switch subscription sessions on, a session with agreementId
answers SUBSCRIPTIONS_NOT_ENABLED, and the agreement stays pending.
With scheduled charges on, the platform charges the saved card once
per period, and you do not call chargePaymentMethod() for these charges. Until we switch
scheduled charges on, the platform runs no scheduled charge and no retry.
- Create the plan once.
- Create an agreement for each customer. It starts
pendingand charges nothing. - Send the customer through a subscription session:
createCheckoutSession()with theagreementId. The session takes the first payment, saves the card and activates the agreement.
$plan = $client->createPlan([ 'name' => 'Pro monthly', 'amount' => 2500, // minor units 'currency' => 'EUR', 'intervalUnit' => 'month', // day, week, month or year 'intervalCount' => 1, 'idempotencyKey' => 'plan-pro-monthly', ]); $agreement = $client->createAgreement([ 'planId' => $plan['id'], // plan_... 'customerReference' => 'customer-8817', 'idempotencyKey' => 'agreement-customer-8817-pro', ]); $session = $client->createCheckoutSession([ 'amount' => 2500, 'currency' => 'EUR', 'orderReference' => 'customer-8817-pro-first', 'idempotencyKey' => 'subscribe-' . $agreement['id'], 'agreementId' => $agreement['id'], // agr_... 'consent' => [ 'textVersion' => 'recurring-2026-09', // a version from the contract's recurring.consentTextVersions 'acceptedAt' => $acceptedAt, // when the customer accepted, ISO 8601 UTC 'ip' => $customerIp, ], ]);
listPlans()returns at most 200 plans, active first, with no pages.getPlan()reads one.updatePlan()changes onlyname,trialDaysandcancelUrl. The amount, the currency and the interval never change: create a new plan instead.deletePlan()archives the plan. Agreements on it continue on their own copy of it.getAgreement()reads an agreement.cancelAgreement()cancels it: no further charge is scheduled, and a charge already placed is not reversed. Cancelling twice is safe.createPlan()andcreateAgreement()need anidempotencyKey. The same key with the same body answers the first object again. Derive the key from your own record, never at random.- A refusal is an
ApiException. Branch ongetErrorCode(), for examplePLAN_NOT_FOUNDorAGREEMENT_CONSENT_REQUIRED.
The status read of the subscription session has agreement: its id, status and
nextChargeAt. The status turns active when the first payment is approved. After a decline
or an abandoned session it stays pending. To follow the agreement after that, use the
agreement.* and charge.* events (see "Agreement and charge events: order by sequence").
Refunds
createRefund() refunds a payment, all of it or part of it. $transactionId is the id
createCheckoutSession() or chargePaymentMethod() returned; only card-not-present payments
of your own account can be refunded, any other id is PAYMENT_NOT_FOUND. A refund is always
in the payment's currency, and the amount is in minor units by the same rules as a payment
(see Amounts are minor units).
createRefund() on a payment made on a terminal answers 404 PAYMENT_NOT_FOUND. You can
refund at most the paid amount. Whoever holds your payments key can refund, so keep that key
on your server.
use Dominaite\Exception\RefundException; try { // Part of a HUF payment: HUF has no decimals on the gateway, so this is 1500 HUF. $refund = $client->createRefund($transactionId, [ 'amount' => DominaiteClient::toMinorUnits('1500', 'HUF'), 'reason' => 'Returned item', // optional, at most 500 characters // Derived from YOUR refund (return or credit-note id), never random per attempt. 'idempotencyKey' => 'refund-' . $creditNoteId, ]); $db->saveRefund($creditNoteId, $refund['refundId']); // re_..., status pending // Everything still refundable: leave amount out and no amount is sent. // $client->createRefund($transactionId, ['idempotencyKey' => 'refund-' . $creditNoteId]); } catch (RefundException $e) { switch ($e->getErrorCode()) { case 'REFUND_AMOUNT_EXCEEDED': // 422: more than what is left, the message says how much is case 'PAYMENT_NOT_REFUNDABLE': // 422: not paid, already fully refunded, or all of it in progress // Nothing was queued and the key is not burnt. break; case 'DUPLICATE_REQUEST': // 409: this key is in flight, retry the SAME key shortly break; case 'IDEMPOTENCY_KEY_REUSED': // 422: the key was used for a different refund, a bug on your side case 'PAYMENT_NOT_FOUND': // 404: not a card-not-present payment of yours break; } }
The refund runs asynchronously: the 202 means queued, not done. idempotencyKey is
required and signed like a charge's, and the same key always answers the same refundId
and never refunds twice; a replay answers with the refund as it stands now. A 500 (a
TransportException) means nothing was queued: retry with the same key,
getLastIdempotencyKey() reads it back. RefundException extends ApiException and
isRetryable() tells the two retryable codes (DUPLICATE_REQUEST, REFUND_NOT_FOUND) from
the rest.
Read the outcome with getRefund(), or wait for the payment.refunded webhook:
$refund = $client->getRefund($transactionId, $refundId); // ['refundId' => 're_...', 'transactionId' => ..., 'status' => 'succeeded', 'amount' => 1500, // 'currency' => 'HUF', 'failureCode' => null, 'failureMessage' => null, // 'completedAt' => '2026-09-26T10:05:40.1200000Z']
statusispending(queued),processing(with the payment provider),succeededorfailed.succeededandfailedare final. A failed refund is final for its key: a new attempt needs a new key.amountis the amount requested whilepending(null for a full refund), the amount being refunded whileprocessing(null until a full refund has been sized), the amount actually refunded once it has succeeded, and always null on a failed refund.failureCodeis set on a failed refund only:REFUND_FAILED,REFUND_AMOUNT_EXCEEDEDorPAYMENT_NOT_REFUNDABLE(DominaiteClient::REFUND_FAILURE_CODES). Treat a code you do not know asREFUND_FAILED.REFUND_FAILEDis never an HTTP error.- Right after the 202 the refund may not be picked up yet:
getRefund()then throwsRefundExceptionwithREFUND_NOT_FOUND. Poll again for up to 60 seconds. - The gateway omits null fields on the wire; the SDK reads them as null, so every key is always there.
A completed refund fires payment.refunded, once per refund, partial or full. On it,
data.transactionId is the refund's own transaction id, data.amount is that refund's
amount and data.originalTransactionId is the payment you refunded. A failed refund fires
no webhook: poll getRefund() if you need to know about failures.
List the refunds of a payment
listRefunds() returns the refunds of one payment, with its totals:
$list = $client->listRefunds($transactionId); // ['transactionId' => ..., 'currency' => 'HUF', 'refundedAmount' => 1500, // 'refundableAmount' => 6940, 'refunds' => [[... the shape of getRefund() ...]]]
refundshas the refunds created through this API, and is empty when there are none. Pending refunds come first, then processing ones, then the final ones. Each group is newest first.refundedAmountalso counts refunds made in the dashboard.refundableAmountis what a new refund can take now.- An unknown payment is a
RefundExceptionwithPAYMENT_NOT_FOUND.
List your transactions
listTransactions() lists your card-not-present payments in a time window, page by page. It
needs a reporting key with the transactions:read scope (see "Scopes" above).
$reporting = new DominaiteClient(getenv('DOMINAITE_REPORTING_KEY_ID'), getenv('DOMINAITE_REPORTING_SECRET')); $params = ['from' => '2026-10-01T00:00:00Z', 'to' => '2026-10-08T00:00:00Z', 'limit' => 100]; do { $page = $reporting->listTransactions($params); foreach ($page['items'] as $item) { // transactionId, orderReference, type, status, amount, currency, createdAt, // completedAt, storedPaymentMethodId, refundedAmount } $params['cursor'] = $page['nextCursor']; } while ($page['nextCursor'] !== null);
fromis inclusive andtois exclusive, oncreatedAt. Both are required, at most 31 days apart. Use theZform, for examplegmdate('Y-m-d\TH:i:s\Z', $time).statusis optional and keeps the payments that have that status now.limitis 1 to 100, 50 when you leave it out.- Rows come oldest first. Send
nextCursorback ascursor, with the samefrom,toandstatus, until it isnull. Paging never repeats or skips a row. typeispayment,chargeorverification. Averificationis not a purchase, so leave it out of revenue.- A refund is not a row of its own. It shows on its payment as
refundedAmountandstatus. Fees are not in this read. - The SDK sends the parameters in the query string. The signature covers the path only.
Fallback: status polling
Use this when you have not registered a webhook endpoint yet, inside your reconciliation sweep, or any time you need the current state of one specific payment on demand.
$status = $client->getStatus($session['transactionId']); // ['transactionId' => ..., 'orderReference' => 'order-1042', 'status' => 'succeeded', // 'amount' => 2500, 'currency' => 'EUR', ...]
status is one of: pending, processing, succeeded, failed, refunded,
partially_refunded, cancelled, disputed, requires_capture, abandoned. While the
session is still payable, expiresAt says until when; after that instant a pending session
can only become abandoned. An unknown transaction id throws an ApiException with HTTP 404.
orderReference, refundedAmount, updatedAt and expiresAt are always in the array, and
null when they have no value.
succeeded is the only value that means the payment is complete. Keep polling on pending,
processing and requires_capture - none of them is terminal.
requires_capture is not "unpaid": the payer has already paid and the funds are held
awaiting capture. Never treat it as an abandoned order.
Treat any status you do not recognise as still-open as well: a value the API adds later should make you keep polling, never silently close an order that is still live.
paymentMethod says how the payer paid (card, wallet, bank_transfer or sepa) and
walletType names the wallet when it is wallet (apple_pay, google_pay, samsung_pay).
Both are null while the payment is still open and on older transactions, walletType is null
for non-wallet payments, and older gateways leave the keys out, so read them with ?? null. This is reporting data, not a money-flow switch: a wallet payment refunds, captures
and disputes like a card. A walletType not in DominaiteClient::WALLET_TYPES is still a
valid wallet, not an error.
The status read also has these fields. The API leaves some of them out when they have no
value, so read each one with ??:
declineClass: why a failed payment was declined (DominaiteClient::DECLINE_CLASS_VOCABULARY). It is null on every status other thanfailed. It can be null onfailedtoo, for example when nobody declined the payment.isRecurring:truefor a charge on a saved card,falsefor a checkout session.verifyOnly:truefor a verify-only session. Other sessions do not have it.agreement: theid,statusandnextChargeAtof the subscription agreement, or null.sequence: thedata.sequenceof the latestcharge.*event about this payment, 0 before the first one.
Two helpers encode those rules so you do not have to:
DominaiteClient::isPaid($status['status']); // true only for 'succeeded' DominaiteClient::isTerminal($status['status']); // stop polling: succeeded, failed, cancelled, // abandoned, refunded, partially_refunded
Everything else, disputed and unknown values included, is not terminal.
Poll after the payer returns to you, or on your order timeout - not in a tight loop; the endpoint is rate limited per key.
Recovering from a replay refusal
When your idempotency key collides with an earlier attempt, the refusal names the transaction it collided with, so you can reconcile instead of minting a second payment:
try { $session = $client->createCheckoutSession($params); } catch (CheckoutRefusedException $refusal) { if ($refusal->getTransactionId() !== null) { $status = $client->getStatus($refusal->getTransactionId()); // Now you know what the earlier attempt actually did. } }
getTransactionId() is null when the API did not name one (a concurrent-race
DUPLICATE_REQUEST knows the key is taken but not yet by which row), so check it before use.
DUPLICATE_REQUEST means a session for this key is open, or expired within the last few
minutes: re-POST the same key shortly, never a fresh one. The full refusal payload is on
getResult().
One replay is not a refusal at all. A session that expired unpaid is superseded: from a few
minutes past its expiry, re-POSTing the same key returns an ordinary success with a fresh
session (new transaction id, same key), so a customer who comes back late just pays. Keep
the order-derived key for the life of the order to keep that path open. The band is not
endless - once the platform has independently closed the attempt (about an hour past
expiry), the replay answers PRIOR_ATTEMPT_FAILED and the key is spent; reconcile and use
a fresh key.