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.
| Role | Responsibility |
|---|---|
| Acceptor | Exposes the endpoints, moves the money, emits notifications |
| Initiator | Creates 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 implementation4pay.online returns
startedandfinishedas2026-09-03 11:18:30.101839 Etc/UTC— a space whereTbelongs, 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:
| Condition | What happens |
|---|---|
| Key expiry | A key MAY carry an expiry. Past it the key stops authenticating, with the same 401 as an unknown one |
| Source address | A partner MAY be restricted to a list of source addresses. From anywhere else a valid key does not authenticate |
| Organization state | The 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:
- 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.
- 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.
- Scale is resolved from the triple (currency, network, environment), not
from the ticker.
USDTis 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.
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.
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.
| Field | Type | Description |
|---|---|---|
id | uuid | Acceptor's identifier. Assigned on creation, immutable |
txid | string | Your identifier. Free-form; unique per partner. See §6.1 |
type | enum | payment — money in; payout — money out |
status | string | See §8 |
error_description | string | Human-readable reason. Empty on success |
amount | money | What the payer pays, in the transaction currency |
amount_with_fee | money | amount plus fees charged on top |
amount_dest | money | What arrives at the destination |
merchant_amount | money | What the initiator receives after fees |
currency | string | Source currency |
currency_dest | string | Destination currency. Equals currency unless converted |
currency_rate | string | Rate applied, when the two differ |
network | string | Chain or rail qualifier. Present only when meaningful |
env | enum | prod or test. See §11 |
description | string | Free text supplied by the initiator |
merchant_meta | object | Your opaque data, returned unchanged. See §6.1 |
started | timestamp | Creation time |
finished | timestamp | Time the transaction reached a final status |
widget_url | url | Hosted page for the payer, when the acceptor hosts one |
auth_params | object | Payer action. See §7 |
additional_params_for_auth | object | Material the initiator must sign or complete |
route | string | Rail chosen by the acceptor |
refund_id | string | Identifier of the refund, once one exists |
refund_msg | string | Outcome 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:
| Field | Limit |
|---|---|
amount | Strictly greater than zero. "0" and negatives are refused |
txid | At most 255 characters |
description | At 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:
| Field | Constraint |
|---|---|
cardnumber | Digits only, and passes the Luhn check |
cvv | Exactly three digits |
exp_month | One or two digits |
exp_year | Two or four digits |
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" }
}
}
| Field | Required | Notes |
|---|---|---|
type | yes | payment or payout |
amount | yes | Decimal string of minor units |
currency | yes | ISO 4217 or asset ticker |
txid | recommended | Your identifier. Free-form string up to 255 characters; enables idempotency |
env | yes in practice | prod or test. Absent means prod — see §11 |
hosted | for hosted flows | true sends the payer to the acceptor's page. No default — see below. show_widget is an accepted alias |
returnUrl / failUrl | for server-to-server payments | Where the payer lands afterwards. Required for a server-to-server payment; optional for hosted payments and payouts |
money_storage | for server-to-server | The payer's credentials. Omitted and hosted unset is refused, not defaulted |
network | when ambiguous | Send it if the ticker exists on several chains |
description | no | Up 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_meta | no | Opaque 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.
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://…" }
}
}
type | Meaning |
|---|---|
none | Nothing is required from the payer through this channel |
redirect | Send the payer to url with method and params |
auth_3ds_form | Same 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_urlisnullon any transaction that hasauth_params. The two are alternatives, not companions, and an initiator that waits forwidget_urlon an action-bearing transaction waits forever.- Some rails deliberately publish no action to the initiator — on the reference acceptor,
mtsbankandcorefay. Thereauth_paramsisnull,widget_urlisnull, andsimple_auth_redirect_urlis 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.
| Status | Final | The money |
|---|---|---|
created | no | Nothing has moved |
new | no | Accepted, not yet routed |
authorizing | no | Being authorised |
action_required | no | Waiting for the payer |
charging | no | Being processed by the rail |
charged | yes | Settled |
rejected | yes | Not settled — refused by limits, anti-fraud or the rail |
failed | yes | Not settled — processing error, or cancelled before settlement |
reversed | yes | Settled, then returned. The return is its own transaction |
refunded | no | Refund 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.
chargedMAY later becomereversed— 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 emitfailedafterrejected. 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. reversedMUST NOT be used for a cancellation before settlement. That isfailed.- The three final outcomes are not interchangeable.
chargedmoved money,rejectedandfailedreleased a hold, andreversedmoved 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" }
}
}
| Field | Description |
|---|---|
event_id | Identifier of the event. Stable across delivery attempts — the deduplication key |
timestamp | Unix seconds at which this attempt was formed |
type | transaction.<status> |
data | The 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
timestampdiffers 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.
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.
| HTTP | Meaning |
|---|---|
400 / 422 | The request is malformed or fails validation |
401 | Missing or unknown key, or missing organization header |
403 | The key is valid but not entitled to this operation |
404 | No such transaction under this partner |
409 | The transaction cannot move as requested from its current state |
429 | Too many attempts on a resource the request touches — for example a gift card being redeemed. Body is {"error": "too_many_attempts"} |
503 | Temporary. 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.
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-keyplusx-organization-idand isolates organizations; - accepts §6.1 and §6.2
with the envelope, string amounts and per-partner
txididempotency; - 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
prodandtestisolated.
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.