Skip to main content

Universal Transaction Protocol — version 1.0

Status: normative for implementations targeting UTP 1.0 Last updated: 2026-09-03

The Universal Transaction Protocol (UTP) is the wire contract behind every payment and payout on 4pay.online. This page is the formal specification of that contract, written so that a system other than ours can implement it.

The pages Transactions, Webhooks and Payment Flows are the tutorial view of the same contract. Where a tutorial and this specification disagree, this specification wins.


1. Roles​

UTP has exactly two roles.

RoleResponsibility
AcceptorExposes the endpoints, moves the money, emits notifications
InitiatorCreates transactions, reads their state, consumes notifications

4pay.online implements both. It is an acceptor for merchants integrating with it, and an initiator when it drives another platform that speaks this protocol. That symmetry is the point: two platforms can be chained without either one learning the other's dialect.

Conformance is stated per role. An implementation MAY be an acceptor only.


2. Conventions​

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as described in RFC 2119.

  • Transport is HTTPS with TLS 1.2 or newer. Plain HTTP MUST be refused.

  • Request and response bodies are application/json, encoded in UTF-8.

  • Timestamps are ISO 8601 with an explicit offset.

    Known deviation in the reference implementation

    4pay.online returns started and finished as 2026-09-03 11:18:30.101839 Etc/UTC — a space where T belongs, and a named zone rather than an offset. The value is UTC and unambiguous, but it is not ISO 8601, and most standard parsers refuse it or, worse, read it as local time. An initiator MUST parse both forms. Cursor values in listings are ISO 8601.

  • Identifiers generated by an acceptor are UUID v4 unless stated otherwise.

  • Unknown fields in a request MUST be ignored rather than rejected, so that a 1.0 acceptor stays usable by a 1.1 initiator.


3. Authentication​

Two headers, not one.

POST /api/v1/transactions HTTP/1.1
Host: acceptor.example
x-api-key: 9f3c…
x-organization-id: 00000000-1111-2222-3333-444444444444
Content-Type: application/json

x-api-key carries the credential. x-organization-id names the organization the key belongs to, and an acceptor resolves the key inside that organization only — organizations are isolated down to the storage layer, so without it there is nowhere to look the key up. A request carrying the key alone is not authenticated and is refused with 401. Your operator gives you both values together.

The key identifies a partner — your merchant identity inside the organization. It does not identify a terminal: the terminal, and therefore the rail the money takes, is chosen by the acceptor's routing after the transaction is created. One partner may have several keys; all of them act as the same partner.

Acceptors MUST reject a missing or unknown key with 401 and MUST NOT reveal whether the key merely lacks a permission.

Three further conditions can turn a correct key into a 401, and an operator settles all three with you before you go live:

ConditionWhat happens
Key expiryA key MAY carry an expiry. Past it the key stops authenticating, with the same 401 as an unknown one
Source addressA partner MAY be restricted to a list of source addresses. From anywhere else a valid key does not authenticate
Organization stateThe organization named in the header must be active. While it is being provisioned, in maintenance or archived, every key inside it fails — the response says nothing about the organization's state

None of the three is distinguishable from an unknown key by the response alone. That is deliberate — see the sentence above — so diagnose them with your operator rather than from status codes.

A second, browser-facing mechanism exists for hosted payment pages, where the payer's browser — not your server — talks to the acceptor: a short-lived widget token issued for one transaction, plus a public read path scoped by organization. It is out of scope for a server-to-server integration and is not specified here.


4. Money​

Every monetary value in UTP is a decimal string of minor units.

{ "amount": "1000" }

"1000" is 10.00 USD, and also 0.001 USDT, and also 0.000000000000001 ETH — the scale is a property of the asset, not of the number.

Three rules follow, and all three are load-bearing:

  1. Never a floating-point number. An 18-decimal asset does not survive IEEE 754, and a JSON number large enough for wei does not survive several widely used parsers.
  2. Never a bare integer either. The same overflow argument applies to language runtimes with 53-bit safe integers, so the value travels as a string and is parsed as an arbitrary-precision integer.
  3. Scale is resolved from the triple (currency, network, environment), not from the ticker. USDT is six decimals on Ethereum mainnet and may be eighteen on a test network. An implementation that hardcodes a scale per ticker is not conformant.

Currencies are ISO 4217 alphabetic codes; digital assets use their ticker.

The reference acceptor takes a closed set

4pay.online accepts RUB, UAH, AZN, USD, EUR, GBP, CHF, TRY, KZT, CAD, SGD, IDR, RUBT, USDT, USDC, EURT, ETH, TRX, TON and ZOLT, and refuses anything else with 422 and unsupported currency <code>. Numeric ISO 4217 codes (643, 840) are not accepted — send the alphabetic code. Omitting currency is still permitted and yields RUB.

The set grows with the rails an operator configures, not with the ISO standard: JPY and PLN are valid ISO codes and are still refused.

Until 3 September 2026 an unsupported code was silently replaced with RUB: "JPY" returned 201 and a rouble transaction. If you integrated against that behaviour, send a supported code.

Where the same ticker exists on more than one chain, send network.

The reference acceptor does not refuse a missing network

It resolves network from the credentials or the terminal when it can, and when it cannot the transaction is created with network empty — no refusal. The scale then stays unresolved and the amount is stored without one, which is exactly the ambiguity the rule above exists to prevent. Send network yourself on any multi-chain asset. ::: Fees and percentages are expressed in basis points (2.5% = "250"), never as decimals.

currency_rate is the exception and is not basis points: it is the exchange rate itself, a decimal string such as "92.5478". Basis points apply to the markup on top of a rate, not to the rate. Reading currency_rate as basis points is off by four orders of magnitude.

Rounding, wherever a party must round, is down — towards minus infinity, so that the acceptor never rounds in its own favour on either sign.


5. The transaction object​

A transaction is the single object of the protocol. Payments, payouts and refunds are all transactions.

FieldTypeDescription
iduuidAcceptor's identifier. Assigned on creation, immutable
txidstringYour identifier. Free-form; unique per partner. See §6.1
typeenumpayment — money in; payout — money out
statusstringSee §8
error_descriptionstringHuman-readable reason. Empty on success
amountmoneyWhat the payer pays, in the transaction currency
amount_with_feemoneyamount plus fees charged on top
amount_destmoneyWhat arrives at the destination
merchant_amountmoneyWhat the initiator receives after fees
currencystringSource currency
currency_deststringDestination currency. Equals currency unless converted
currency_ratestringRate applied, when the two differ
networkstringChain or rail qualifier. Present only when meaningful
envenumprod or test. See §11
descriptionstringFree text supplied by the initiator
merchant_metaobjectYour opaque data, returned unchanged. See §6.1
startedtimestampCreation time
finishedtimestampTime the transaction reached a final status
widget_urlurlHosted page for the payer, when the acceptor hosts one
auth_paramsobjectPayer action. See §7
additional_params_for_authobjectMaterial the initiator must sign or complete
routestringRail chosen by the acceptor
refund_idstringIdentifier of the refund, once one exists
refund_msgstringOutcome of the refund attempt, including a refusal

An acceptor MAY return additional fields. An initiator MUST tolerate them.

Limits the reference acceptor enforces before a transaction is created. All of them refuse the whole request rather than truncating or defaulting:

FieldLimit
amountStrictly greater than zero. "0" and negatives are refused
txidAt most 255 characters
descriptionAt most 2000 characters, and it must be a string

Fields absent on a payout. auth_params and additional_params_for_auth are rendered only for payments. A payout that needs something from the payer exposes it through simple_auth_redirect_url (§7), never through those two.

description is not what you sent. It is echoed from the initiator's text only after the acceptor has chosen a terminal, so a read before routing — the response to a hosted create, or a transaction that was refused before a terminal was picked — returns description: null even though you supplied one. And a terminal MAY be configured to replace the text shown to the payer with its own; then your text is kept for the record but the payer never sees it. Treat description as advisory and reconcile on txid.

5.1 Credentials — money_storage​

Payment credentials travel in a single object called money_storage, whose type selects the shape of the remaining keys.

{
"money_storage": {
"type": "bank_card",
"cardnumber": "4111111111111111",
"cardholder": "IVAN PETROV",
"exp_month": "12",
"exp_year": "2030",
"cvv": "123"
}
}

For type: "payment" all five card fields are required — cardnumber, cardholder, exp_month, exp_year, cvv. For type: "payout" only cardnumber is.

The reference acceptor validates them before the transaction reaches a rail, and refuses the create with 422 when any of these does not hold:

FieldConstraint
cardnumberDigits only, and passes the Luhn check
cvvExactly three digits
exp_monthOne or two digits
exp_yearTwo or four digits
American Express

The three-digit rule has no exception, and an Amex CID is four digits. Amex cards are therefore refused with invalid cvv on this acceptor.

The universal types are bank_card, bank_account, sbp, qr_code, crypto, e_wallet, phone_payment (mobile money and carrier billing), bnpl, open_banking, direct_debit, apple_pay, google_pay, voucher and gift_card. Note the spelling: e_wallet and phone_payment, with the underscore.

direct_debit carries a second required key, scheme, whose value is ach (US), acss (CA) or bacs (UK); the remaining keys depend on it, and each scheme adds its own — a routing or sort code and an account number, with acss accepted for payments only. Omitting scheme is refused with direct_debit requires scheme: ach, acss, or bacs. These three are schemes, not types — sending "type": "ach" is refused with invalid money_storage type ach.

Beyond these an acceptor MAY accept rail-specific types — the reference acceptor also takes totp, provider_token, square_nonce, sepa, upi, netbanking, boleto, revolut_payout, encrypted_qr, native_gas, username_subscription, and a set of redirect passthrough names (pix, oxxo, klarna, alipay, wechat_pay, paynow, grabpay, ideal, bancontact, paypal_account, venmo, emi, interac_present, mollie_redirect, revolut_checkout, open_banking_redirect). An unrecognised type is refused, so an acceptor declares its set when the terminal is configured — there is no discovery endpoint in 1.0.

money_storage is cardholder data. An acceptor MUST NOT write it to logs, error messages, URLs or notification bodies, MUST encrypt it at rest, and MUST erase the verification value once the transaction reaches a final status. An initiator that cannot meet PCI DSS obligations MUST use the hosted flow (§6.3) so the credentials never touch it.


6. Operations​

6.1 Create a transaction​

POST /api/v1/transactions

The body is an envelope with a single params object. The envelope is mandatory; a bare object MUST be rejected.

{
"params": {
"type": "payment",
"amount": "1000",
"currency": "USD",
"txid": "order-12345",
"env": "prod",
"description": "Order 12345",
"returnUrl": "https://merchant.example/success",
"failUrl": "https://merchant.example/fail",
"merchant_meta": { "order_ref": "12345" }
}
}
FieldRequiredNotes
typeyespayment or payout
amountyesDecimal string of minor units
currencyyesISO 4217 or asset ticker
txidrecommendedYour identifier. Free-form string up to 255 characters; enables idempotency
envyes in practiceprod or test. Absent means prod — see §11
hostedfor hosted flowstrue sends the payer to the acceptor's page. No default — see below. show_widget is an accepted alias
returnUrl / failUrlfor server-to-server paymentsWhere the payer lands afterwards. Required for a server-to-server payment; optional for hosted payments and payouts
money_storagefor server-to-serverThe payer's credentials. Omitted and hosted unset is refused, not defaulted
networkwhen ambiguousSend it if the ticker exists on several chains
descriptionnoUp to 2000 characters. Shown to the payer only where the rail allows it and the terminal is not configured to substitute its own text — see §5
merchant_metanoOpaque initiator data, returned unchanged

The response is 201 with the transaction object.

A 201 is not an acceptance. An acceptor refuses a transaction by creating it in a terminal refused state — rejected with a reason in error_description — rather than by returning an HTTP error. Routing failures, limit breaches and anti-fraud decisions all arrive this way. An initiator MUST read status on every response and MUST NOT infer acceptance from the status code.

Choosing the flow. hosted has no default in the reference implementation. A create that sets neither hosted/show_widget nor money_storage is read as server-to-server, finds no credentials, and is refused as unexpected card. An initiator MUST state which flow it wants.

Idempotency. txid is any string you choose and is unique per partner. Repeating a create request with a txid that already exists does not create a second transaction: the acceptor returns the existing one, again with 201 and a location header pointing at it. Two partners inside one organization may use the same txid without colliding.

A transaction created without txid has no idempotency at all — a retry after a timeout produces a second transaction, and therefore a second payment. Send a txid for anything that involves money.

Metadata. merchant_meta is yours: the acceptor stores what you send, returns it on reads and echoes it in notifications. lang is reserved — the acceptor sets it from your partner settings and uses it to pick the language of status descriptions.

It is not, however, returned strictly unchanged. Some rails write their own keys into the same object and you get them back alongside your data — rail-specific tokens on card rails, a status key on some crypto rails, and confirmation bookkeeping in the billing flow. Read your own keys by name and ignore what you did not put there; do not compare the object for equality with what you sent, and do not use a key name a rail might claim.

Notification address. The destination of status notifications is part of the terminal's configuration, not of the request. There is no per-request notification field in UTP 1.0, and an acceptor ignores one if sent.

6.2 Read a transaction​

GET /api/v1/transactions/{id}

Returns the transaction object. This is the reconciliation path: an initiator that has missed a notification MUST be able to recover the whole state from this call alone.

Both headers are required here as well. A read without x-organization-id, or with a key that does not resolve inside it, is refused with 401 — the same rule as §3.

Two fields are not yet populated on a read before the acceptor has chosen a terminal: description (see §5) and route. Reconcile on txid and status, which are set from the start.

6.3 Continue a hosted transaction​

POST /api/v1/transactions/continue/{id}
{
"money_storage": { "type": "bank_card", "cardnumber": "…" },
"route": "card",
"browser_data": { "language": "en-US", "timezone": -180, "user_agent": "…" }
}

Submits the credentials collected on the hosted page and resumes processing. This body has no params envelope — it is the one operation that takes a bare object, because it is posted by the payment page rather than by your server. browser_data carries what 3-D Secure 2.0 requires from the payer's browser and SHOULD be sent whenever a card rail may be selected.

6.4 Two-phase: hold, then capture​

A two-phase transaction holds the funds at creation and captures them on a second, explicit call.

POST /api/v1/transactions/commit/{id}
{ "params": { "money_storage_meta": {} } }

Between the two calls the money is reserved and not settled. The transaction is not final, and the initiator has four ways out: capture it with this call, cancel it explicitly (§6.5 with type: "cancel"), let it expire, or let it fail. An acceptor MUST release the hold when the authorisation expires, and MUST NOT capture more than the held amount.

The reference acceptor has no expiry mechanism

An authorisation that is never captured stays in payment_suspended or payout_suspended indefinitely, and its hold is never released: the integrity sweep only acts on final statuses, and both of these count as in flight. Nothing times out on its own.

Until that is implemented, an initiator that opens a two-phase transaction MUST close it — capture it or cancel it with type: "cancel". Walking away leaves the payer's funds held with no end date.

Whether a given terminal is one-phase or two-phase is a property of the rail behind it and is settled at configuration time.

6.5 Refund​

PUT /api/v1/transactions/{id}
{ "params": { "type": "refund", "amount": "500" } }

type is required, and its only values are refund and cancel. This one endpoint carries both operations: cancel voids an authorisation through the rail's own cancel path, refund returns settled money. A body without type is refused with 422, and so is any other value.

Omit amount for a full refund. The outcome appears on the transaction as refund_id and refund_msg; refund_msg carries the provider's reason when a refund is refused.

amount, when sent, MUST be a positive integer of minor units not greater than the transaction's own amount. The acceptor checks this itself, before the rail sees the request, and refuses with 422 and invalid_refund_amount — a zero, a negative value, a non-integer or an amount above the original never reaches the provider.

A transaction that never reached a provider — refused during routing, by limits or by anti-fraud — has nothing to return. Such a request is refused with 422 and not_reversible.

Partial refunds depend on the rail: the acceptor forwards the amount you asked for, and not every provider honours a partial one — some return the full settlement regardless. Treat amount as a request, and reconcile the refund from refund_id and the resulting transaction rather than assuming it was applied.

A refund does rewrite the status of the original transaction. There is no separate refund record: the rail's answer is applied to the transaction you refunded, and its status becomes refunded — on the reference acceptor that is what 99 rail integrations return from a refund, and not one of them returns reversed.

reversed exists (§8) but does not come from this call. It arrives from status polling on the few rails that report a settlement reversal of their own accord. So a transaction you refunded reads back as refunded, a transaction the rail reversed reads back as reversed, and an initiator that keys its books on charged staying charged will be wrong on both.

6.6 Payer-supplied authentication material​

POST /api/v1/transactions/additional_params_for_auth/{id}
{ "additional_params_for_auth": { "otp": "123456" } }

This operation takes a bare object, without the params envelope, and the key additional_params_for_auth is required. The contents of that object are rail-specific.

The transfer is one-way: material goes in, the acceptor continues the flow. Until 3 September 2026 a status key inside that object was written straight into the transaction's own status, which let an initiator mark its own unpaid transaction as settled. It is ignored now, and status comes only from the rail.

Used where the payer, not the acceptor, completes the authentication: a one-time password, or a signature produced by a wallet the acceptor does not hold keys for.

When the acceptor has prepared material for signing, it exposes it in additional_params_for_auth. On chain-backed rails that reserve a sequence number for the signature it also sets expires_at, and material submitted afterwards is refused rather than queued — a late signature would collide with a later transaction. The initiator requests fresh material and starts again. Rails without such a reservation set no deadline.

6.7 Destination choice​

POST /api/v1/transactions/choose_target_destination/{id}
{ "target_bank_id": "100000000111" }

Also a bare object, without the params envelope. target_bank_id is required; without it the acceptor answers 400 and {"error": "id and target_bank_id are required"}.

For payout rails where the payer selects the receiving institution, the acceptor publishes the options in availble_destinations and the initiator returns the chosen one.


7. Payer actions​

When a transaction needs something from the payer, the acceptor puts an action in auth_params. Its type selects the shape.

{
"auth_params": {
"type": "redirect",
"version": "1",
"method": "post",
"url": "https://acs.bank.example/3ds",
"params": { "PaReq": "eJx…", "MD": "…", "TermUrl": "https://…" }
}
}
typeMeaning
noneNothing is required from the payer through this channel
redirectSend the payer to url with method and params
auth_3ds_formSame shape, rendered as a self-submitting form — put it in an iframe

method is get or post, lower case. params MUST be a flat map of string to string — nested structures have no defined encoding as form fields, and an acceptor MUST NOT produce one.

This object is a projection, not storage. Internally an acceptor keeps whatever its rail integration needs; what crosses the wire is only the shapes above. An initiator that renders actions generically — read type, build the request, send the payer — needs no per-rail code. That is the whole purpose of the object.

none is a legitimate answer, and it does not mean the payment is stuck. Rails whose next step is not a redirect — a QR code to scan, a deeplink into a banking app, a bank-rendered HTML form — have no action in this vocabulary.

The universal fallback is simple_auth_redirect_url, a field on the transaction object alongside auth_params. It is an address on the acceptor itself: send the payer there and the acceptor drives whatever the rail needs — renders the QR, submits the bank's form, follows the deeplink. It is present whenever the transaction has anything for the payer to do, including on rails that publish no auth_params at all.

Two consequences an initiator MUST plan for:

  • widget_url is null on any transaction that has auth_params. The two are alternatives, not companions, and an initiator that waits for widget_url on an action-bearing transaction waits forever.
  • Some rails deliberately publish no action to the initiator — on the reference acceptor, mtsbank and corefay. There auth_params is null, widget_url is null, and simple_auth_redirect_url is the only path. This is a property of the rail's agreement, not a fault.

Read simple_auth_redirect_url first, and treat auth_params as the richer form for initiators that want to render the step themselves.

An acceptor MAY expose its internal, rail-specific form instead, for an initiator that deliberately exploits the specifics of one rail. On the reference acceptor that is a per-terminal setting, off by default: ask your operator for it, and expect a shape this specification does not describe.


8. Status model​

The statuses below are the ones an initiator will normally see.

StatusFinalThe money
creatednoNothing has moved
newnoAccepted, not yet routed
authorizingnoBeing authorised
action_requirednoWaiting for the payer
chargingnoBeing processed by the rail
chargedyesSettled
rejectedyesNot settled — refused by limits, anti-fraud or the rail
failedyesNot settled — processing error, or cancelled before settlement
reversedyesSettled, then returned. The return is its own transaction
refundednoRefund completed. Finality is rail-dependent and is deliberately left open in 1.0

The vocabulary is open. Rails carry their own intermediate states — cancelled, payment_suspended, payout_suspended, pre_authorized_3ds, auth_required, refunding, unknown and others — and an acceptor MAY pass one through. An initiator MUST NOT reject an unrecognised status: treat anything outside the table above as non-final and keep polling or waiting. A strict enum will fail on the first uncommon rail.

Rules:

  • An initiator MUST run its business logic only on a final status.
  • charged MAY later become reversed — a settled transaction can be returned at any time.
  • Some rails move out of a final status into a refund sequence (charged → refunding → refunded) or emit failed after rejected. Treat a second notification about a finished transaction as information about a refund or a correction, not as a new payment, and reconcile with §6.2.
  • reversed MUST NOT be used for a cancellation before settlement. That is failed.
  • The three final outcomes are not interchangeable. charged moved money, rejected and failed released a hold, and reversed moved money and then moved it back — an implementation that collapses them into success/failure will double-release holds.

refunded is honestly unfinished: on card rails it behaves as a terminal state and on some payout rails it does not. UTP 1.0 declares it non-final, and an initiator MUST NOT treat it as an outcome.


9. Notifications​

The acceptor pushes status changes to the terminal's configured address — but not every one of them. While routing is still trying terminals, a rejected from one terminal is deliberately suppressed so you do not see a refusal that the next terminal may turn into a payment. You are notified of the outcome, not of every attempt. Reconcile with §6.2 if you need the attempt history.

9.1 Envelope​

{
"event_id": "4d9d0e6c-1d0a-4a2e-9a2f-0c9a1b0e8f21",
"timestamp": 1772668800,
"type": "transaction.charged",
"data": {
"type": "payment",
"txid": "order-12345",
"id": "550e8400-e29b-41d4-a716-446655440000",
"amount": "1000",
"amount_dest": "950",
"amount_with_fee": "1050",
"status": "charged",
"error_description": "",
"merchant_meta": { "order_ref": "12345" }
}
}
FieldDescription
event_idIdentifier of the event. Stable across delivery attempts — the deduplication key
timestampUnix seconds at which this attempt was formed
typetransaction.<status>
dataThe payload proper. Amounts are strings, as everywhere else

9.2 Signature​

x-webhook-event-id:  4d9d0e6c-…
x-webhook-timestamp: 1772668800
x-webhook-signature: 6b1f… HMAC-SHA256, hex

The signature is computed over the string <timestamp>.<body>, where <body> is the exact JSON of the envelope as sent on the wire, keyed with the shared secret configured for your terminal — or, where the acceptor delivers to a partner-level address, with the partner's secret. Verifying the payload alone, or a re-serialised copy of it, will not match.

An acceptor that has no secret configured sends no signature headers. An initiator SHOULD refuse unsigned notifications in production.

9.3 Delivery and retries​

  • Acknowledgement is any 2xx. Body content is ignored.
  • Answer within 15 seconds, and accept the connection within 5. The reference acceptor gives no more than that; a handler that finishes successfully on the sixteenth second still counts as a failed attempt and the notification is retried. Acknowledge first, process afterwards.
  • Failed deliveries are retried after 30 s, 2 min, 10 min, 1 h and 6 h. After the sixth attempt the delivery is parked for manual inspection.
  • The receiver MUST deduplicate on event_id — a delivery may arrive twice, and every attempt at the same event carries the same identifier.
  • The receiver MUST reject an envelope whose timestamp differs from its own clock by more than five minutes.
  • Notifications are not ordered. A receiver MUST reconcile against §6.2 rather than assume the last message it saw is the current state.
A second, unspecified stream shares the address

On the reference acceptor the terminal's notification address also receives subscription lifecycle events — subscription.renewed, payment_failed, cancelled, paused, resumed. These are not UTP notifications and do not follow §9: no event_id, no type, no data, no signature headers, and a timestamp that is an ISO 8601 string rather than unix seconds. Their body is {event, timestamp, subscription, partner_id, initial_payment_id, metadata}, with subscription.amount as a number rather than a string.

A receiver that implements §9 literally — deduplicate on event_id, verify the signature, read data.status — will discard all of them, and the recommendation to refuse unsigned notifications in production will discard them even when a secret is configured. Branch on the presence of event_id before anything else: no event_id means this stream, not a transaction status change.


10. Errors​

{ "errors": [ { "code": "flow_not_continuable", "detail": "status_poll" } ] }

Every error carries an errors array. Each element has a detail string and, on some endpoints, a machine-readable code. Validation failures list one element per field.

Two older shapes are still in use and an initiator MUST accept all three:

{ "error": "invalid_refund_amount", "message": "…" }
{ "error": "wrong params - can't init tx" }

The second — a bare error string with no message — is what §6.1 returns when a create is refused, and what several other endpoints return on 429 and 503. Look for errors[0].detail, then message, then error.

HTTPMeaning
400 / 422The request is malformed or fails validation
401Missing or unknown key, or missing organization header
403The key is valid but not entitled to this operation
404No such transaction under this partner
409The transaction cannot move as requested from its current state
429Too many attempts on a resource the request touches — for example a gift card being redeemed. Body is {"error": "too_many_attempts"}
503Temporary. SHOULD carry retry-after, but on the reference acceptor only the node-unavailable case does. Fall back to your own backoff when the header is absent

A few endpoints predating this format answer with {"error": "…", "message": "…"} instead. Accept both shapes.

An acceptor MUST NOT report a business refusal as a transport error: a declined payment is 200 with a final status, not a 4xx.


11. Environments​

Every transaction carries env, either prod or test, and an acceptor MUST keep the two fully isolated — separate balances, separate identifier spaces, separate rails.

Separate identifier spaces means txid too: the same order number may exist once in test and once in prod, and idempotency (§6.1) matches within one environment only.

Fixed on 3 September 2026

Before that date the reference acceptor scoped txid uniqueness to the partner alone. A merchant who had used order-1 while testing, and then sent order-1 in production, received 201 and the test transaction — the live payment silently never happened. If you carried order numbers over from a test integration, reconcile with §6.2.

The environment is taken from params.env in the request body. A ?env= query parameter selects the environment a listing or lookup searches, and does not set the environment of a transaction being created: a create request carrying only ?env=test produces a production transaction. Always send env inside params.


12. Versioning​

This document is UTP 1.0. Compatible additions — new optional fields, new money_storage types, new action types — arrive as 1.x and require no change from a conforming implementation, which ignores what it does not recognise. A change that invalidates a 1.x implementation requires a new major version.

UTP 1.0 has no capability-discovery endpoint. Which operations, currencies, credential types and phases a terminal supports is agreed when the terminal is configured. Discovery is reserved for 1.1.


13. Conformance​

An acceptor conforms to UTP 1.0 when it:

  • authenticates with x-api-key plus x-organization-id and isolates organizations;
  • accepts §6.1 and §6.2 with the envelope, string amounts and per-partner txid idempotency;
  • reports status from the vocabulary in §8, passing rail states through rather than inventing new final ones;
  • projects payer actions into the shapes of §7 instead of exposing its internal storage;
  • emits signed, retried, deduplicable notifications per §9;
  • reports refusals as final statuses, not transport errors;
  • keeps prod and test isolated.

Additionally, for each capability it advertises: payouts, refunds (§6.5), two-phase capture (§6.4) and hosted collection (§6.3).

An initiator conforms when it sends string amounts, keeps txid stable across retries, renders actions generically, tolerates unknown statuses as non-final, verifies notification signatures, deduplicates on event_id, reconciles by reading rather than by trusting message order, and acts only on final statuses.


14. Reference implementation​

4pay.online is the reference acceptor: everything specified here is the contract its own API serves.

It also ships two initiators. The heth provider adapter drives one specific second platform over this protocol, taking the acceptor's host from terminal configuration rather than from code. The utp adapter is the generic one: point it at any conforming acceptor through terminal parameters — host, API key, organization — and it speaks this specification with no per-acceptor code. Its notification endpoint is /api/ext/{organization_id}/utp/webhook, and it verifies the signature of §9.2.

utp treats a status it does not recognise as charging, never as a refusal: an acceptor that adds a state to its vocabulary must not cause the initiator to abandon money in flight.