Swagger

Transactions

Prepare, sign, submit, and poll an operation through to a terminal status.

The loop every operation runs

One loop serves every operation on this platform: prepare it, have the account sign what the prepare answered, submit that signature, then poll the operation id until it is terminal. A transfer, a swap and a private-token operation differ only in which prepare endpoint builds them.

Prepare transfers from the intended token movements

POST /transaction/transfer takes the intent — one or more same-chain transfer legs, tokens may differ between legs — and the gateway works out how to carry it, on whichever rail the network uses. You describe what should move; you never assemble the call list yourself.

The rail is decided by the prepare, not chosen by you

The network, asset and project determine the rail. Read it from the prepare response, then use the corresponding signing and submit payload. All rails use the same submit and status endpoints.

One submit endpoint, one of two payloads

POST /transaction/submit serves every rail and takes exactly one of signature or signedTransaction — a signature over what the rail returned, or the whole transaction the account signed on the self-funded rail. Re-sending the identical payload is idempotent on both shapes, so a lost response is safe to retry.

A preparation is a snapshot with a deadline

Prepared operations expire after 30 minutes by default; a self-funded swap quote expires after 5 minutes. A self-funded native transfer creates no stored operation until submit. An expired prepare returns 404 on submit: prepare and sign again. For a 409, inspect the code to distinguish an existing operation from an in-flight conflict.

An account's first operation can need one extra field

On networks that support delegation, prepare returns authorizationRequired and authorizationNonce. Attach a fresh EIP-7702 authorization when authorizationRequired is true, even if the account was activated before; the current chain state determines whether it is needed. Omit it when false. Rails without delegation reject the field with 400.

These examples are based on recorded operations and link to their on-chain transactions. Requests and responses are shortened and sensitive fields are withheld. Request formatting follows the current API. Replace placeholders and prepare a fresh operation before signing; these historical payloads are not reusable.

An ERC-20 transfer with no native coin

UserOperation rail (EIP-7702 account) · Arbitrum One

2.148465421470024202 ARB moved to 0xCa9d43184Cc3179609f77D38433D363833C73c60. The account held no ETH and paid the gas cost in ARB through the operation fee.

Operation id
ugtp_operation_E6SnXDoAl9AQ
Charged
0.564385946233880654 ARB, deducted from the same token: 0.112759966958839437 gas + 0.449477513853571193 static + 0.002148465421470024 transfer percentage. ARB was priced at $0.11124027, which is what turns the $0.05 static fee into 0.4494 ARB.

The UserOperation the EntryPoint executed

The same object as in the response, now carrying the account's signature. Its 228-byte callData is one execute(ARB, 0, transfer(recipient, amount)); the 214-byte paymasterAndData carries the paymaster, its gas limits, the fee token, the signed maximum, the transferred value, the validity window, the collection mode and the backend signature.

{
  "sender": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
  "nonce": "0x2",
  "initCode": "0x",
  "callData": "0xb61d27f6000000000000000000000000912ce59144191c…228 bytes",
  "accountGasLimits": "0x000000000000000000000000000084b800000000000000000000000000014393",
  "preVerificationGas": "0xc208",
  "gasFees": "0x00000000000000000000000000000000000000000000000000000000016eafe0",
  "paymasterAndData": "0x49f26881b5ea4d3f3d451c7faa4bca1b4aebcd97…214 bytes",
  "signature": "0xa3087507768f541a4eaa9d46b581c351d29c3137…65 bytes",
  "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
  "entryPoint": "0x433709009B8330FDa32311DF1C2AFA402eD8D009",
  "handleOpsTransaction": "0x5977159b21734798f90fad1e442216dccec579168c00354268085a6e1d608af0"
}

POST /transaction/transfer
{
  "ownerAddress": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
  "networkId": "42161",
  "transfers": [
    {
      "tokenAddress": "0x912ce59144191c1204e64559fe8253a0e49e6548",
      "amount": "2148465421470024202",
      "recipientAddress": "0xCa9d43184Cc3179609f77D38433D363833C73c60"
    }
  ]
}

{
  "operationId": "ugtp_operation_E6SnXDoAl9AQ",
  "userOperation": {
    "sender": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
    "nonce": "0x2",
    "initCode": "0x",
    "callData": "0xb61d27f6000000000000000000000000912ce59144191c…228 bytes",
    "accountGasLimits": "0x000000000000000000000000000084b800000000000000000000000000014393",
    "preVerificationGas": "0xc208",
    "gasFees": "0x00000000000000000000000000000000000000000000000000000000016eafe0",
    "paymasterAndData": "0x49f26881b5ea4d3f3d451c7faa4bca1b4aebcd97…214 bytes",
    "signature": "0x"
  },
  "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
  "ugtpFee": {
    "tokenAmount": "622595557466841530",
    "feeToken": "0x912ce59144191c1204e64559fe8253a0e49e6548"
  },
  "accountType": "eoa_7702",
  "authorizationRequired": false
}

POST /transaction/submit
{
  "operationId": "ugtp_operation_E6SnXDoAl9AQ",
  "signature": "0xa3087507768f541a4eaa9d46b581c351d29c3137…65 bytes"
}

{
  "operationId": "ugtp_operation_E6SnXDoAl9AQ",
  "status": "queued",
  "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
  "networkId": "42161"
}

GET /transaction/:id
{
  "operationId": "ugtp_operation_E6SnXDoAl9AQ",
  "status": "finalized",
  "transactionHash": "0x5977159b21734798f90fad1e442216dccec579168c00354268085a6e1d608af0",
  "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
  "networkId": "42161",
  "replacementCount": 0,
  "reason": null
}

Open the transaction on the block explorer ↗

The same transfer, where there is no EIP-7702

Executor rail (EIP-712 execution request) · Avalanche C-Chain

0.2 USDt moved on Avalanche C-Chain. Same API call as the Arbitrum transfer; the response asks for typed data instead of a UserOperation hash.

Operation id
ugtp_operation_ILmX7eF4Ngf5
Charged
0.050351 USDT: 0.00014 gas + 0.050011 static + 0.0002 transfer percentage. The signed ceiling was 0.050908 USDT, so the charge landed under the maximum the account had agreed to.

The ExecutionRequest the executor received

The account's own EIP-712 signature travels with it, and so does the backend signature that proves UGTP quoted these terms — the contract verifies both and refuses anything the account did not sign. The action stays ABI-encoded: kind 0 is a send.

{
  "account": "0x0a1758ad202a2059e8c2970b562597ca45d863c4",
  "nonce": "0x3",
  "actions": [
    {
      "kind": 0,
      "params": "0x0000000000000000000000009702230a8ea53601f5cd2dc00fdbc13d4d…96 bytes"
    }
  ],
  "terms": {
    "feeToken": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7",
    "transferredValueInFeeToken": "200000",
    "platformTransferFeeBps": 10,
    "projectTransferFeeBps": 0,
    "platformStaticFeeUsd8dp": "5000000",
    "projectStaticFeeUsd8dp": "0",
    "projectPayoutAddress": "0x0000000000000000000000000000000000000000",
    "calldataGas": "7296",
    "maxFeeTokenAmount": "50908",
    "validUntil": 1788518426
  },
  "backendSignature": "0x0c9eb1440086c6f661c9c370fa25d623277990b3…65 bytes",
  "executor": "0xA7C8776C6ac6f21eC923321793F62A4887f9E767",
  "requestHash": "0x8f2f1873e757aea22e8d896fff6e6d03ef1835f922f49cb7fca6ff032fce813f",
  "transaction": "0x6369d823789469741cecf701e9000710b24a3180984dc07e47fdfc277fe1a7ed"
}

POST /transaction/transfer
{
  "ownerAddress": "0x0a1758ad202a2059e8c2970b562597ca45d863c4",
  "networkId": "43114",
  "transfers": [
    {
      "tokenAddress": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7",
      "amount": "200000",
      "recipientAddress": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f"
    }
  ],
  "feeTokenAddress": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"
}

{
  "operationId": "ugtp_operation_ILmX7eF4Ngf5",
  "rail": "forward_request",
  "requestHash": "0x8f2f1873e757aea22e8d896fff6e6d03ef1835f922f49cb7fca6ff032fce813f",
  "signingPayload": {
    "domain": {
      "name": "UGTPAvalancheExecutor",
      "version": "1",
      "chainId": 43114,
      "verifyingContract": "0xA7C8776C6ac6f21eC923321793F62A4887f9E767"
    },
    "types": {
      "ExecutionRequest": [
        {
          "name": "account",
          "type": "address"
        },
        {
          "name": "nonce",
          "type": "uint256"
        },
        {
          "name": "sends",
          "type": "Send[]"
        },
        {
          "name": "swaps",
          "type": "Swap[]"
        },
        {
          "name": "bridges",
          "type": "Bridge[]"
        },
        {
          "name": "privates",
          "type": "Private[]"
        },
        {
          "name": "terms",
          "type": "FeeTerms"
        }
      ],
      "Send": [
        {
          "name": "token",
          "type": "address"
        },
        {
          "name": "recipient",
          "type": "address"
        },
        {
          "name": "amount",
          "type": "uint256"
        }
      ],
      "...": "Swap, Bridge, Private and FeeTerms members follow; the payload carries the whole tree"
    },
    "primaryType": "ExecutionRequest",
    "message": {
      "account": "0x0a1758ad202a2059e8c2970b562597ca45d863c4",
      "nonce": "3",
      "sends": [
        {
          "token": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7",
          "recipient": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
          "amount": "200000"
        }
      ],
      "swaps": [],
      "bridges": [],
      "privates": [],
      "terms": {
        "feeToken": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7",
        "transferredValueInFeeToken": "200000",
        "platformTransferFeeBps": 10,
        "projectTransferFeeBps": 0,
        "platformStaticFeeUsd8dp": "5000000",
        "projectStaticFeeUsd8dp": "0",
        "projectPayoutAddress": "0x0000000000000000000000000000000000000000",
        "calldataGas": "7296",
        "maxFeeTokenAmount": "50908",
        "validUntil": 1788518426
      }
    }
  },
  "ugtpFee": {
    "tokenAmount": "50908",
    "expectedTokenAmount": "50392",
    "feeToken": "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7"
  }
}

GET /transaction/:id
{
  "operationId": "ugtp_operation_ILmX7eF4Ngf5",
  "status": "finalized",
  "transactionHash": "0x6369d823789469741cecf701e9000710b24a3180984dc07e47fdfc277fe1a7ed",
  "userOperationHash": "0x8f2f1873e757aea22e8d896fff6e6d03ef1835f922f49cb7fca6ff032fce813f",
  "networkId": "43114",
  "replacementCount": 0,
  "reason": null
}

Open the transaction on the block explorer ↗

The native coin, which no allowance can move

Self-funded rail (a whole transaction) · Avalanche C-Chain

0.001 AVAX moved. On this rail the account signs the native transfer itself, so it signed the whole transaction and paid its own gas — and UGTP charged nothing.

Operation id
ugtp_operation_d99WHSYNXv4j
Charged
No UGTP fee at all. The operation has a fee row with every amount null, because there is nothing to collect on this rail.

The signed transaction, as broadcast

A plain EIP-1559 transaction — type 0x02, 117 bytes of RLP. There is no UserOperation and no executor in the picture: the operation IS this transaction, which is why its hash and the operation's own hash are the same value.

{
  "type": "0x2",
  "chainId": "0xa86a",
  "nonce": "0x4",
  "maxPriorityFeePerGas": "0x1",
  "maxFeePerGas": "0x5f9510d",
  "gas": "0x7b0c",
  "to": "0x0ea9337dc1055df7bbd16f3d4824b01c560af4c5",
  "value": "0x38d7ea4c68000",
  "data": "0x",
  "raw": "0x02f86f82a86a04018405f9510d827b0c940ea9337dc…117 bytes",
  "transactionHash": "0xd805c96742bc1aed12029fe2e1a78c487b635c0d64f94d8520a6715578f67e17"
}

POST /transaction/transfer
{
  "ownerAddress": "0x760ECC99d6eA52D4D209591E2eC10878cFF4e7E3",
  "networkId": "43114",
  "transfers": [
    {
      "tokenAddress": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
      "amount": "1000000000000000",
      "recipientAddress": "0x0ea9337dc1055df7bbd16f3d4824b01c560af4c5"
    }
  ]
}

{
  "rail": "self_funded",
  "senderAddress": "0x760ECC99d6eA52D4D209591E2eC10878cFF4e7E3",
  "networkId": "43114",
  "nonce": 4,
  "transaction": {
    "to": "0x0ea9337dc1055df7bbd16f3d4824b01c560af4c5",
    "data": "0x",
    "value": "1000000000000000"
  },
  "gasSuggestion": {
    "gasLimit": "31500",
    "maxFeePerGas": "100000013",
    "maxPriorityFeePerGas": "1"
  }
}

POST /transaction/submit
{
  "signedTransaction": "0x02f86f82a86a04018405f9510d827b0c940ea9337dc…117 bytes"
}

{
  "operationId": "ugtp_operation_d99WHSYNXv4j",
  "status": "submitted",
  "userOperationHash": "0xd805c96742bc1aed12029fe2e1a78c487b635c0d64f94d8520a6715578f67e17",
  "networkId": "43114"
}

Open the transaction on the block explorer ↗

Worked example

A transfer with no native coin, end to end

Three calls: prepare, submit what the account signed, then poll until the operation reaches a terminal status.

  1. 01

    Prepare the transfer

    POST /transaction/transfer

    The gateway resolves the account from ownerAddress and picks the rail. Read the response's rail field before you sign anything: it decides whether you sign a hash, typed data, or a whole transaction.

    Input — the request

    curl -X POST "https://api.ugtp.io/v1/transaction/transfer" \
      -H "X-API-Key: $UGTP_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
      "ownerAddress": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
      "networkId": "42161",
      "transfers": [
        {
          "tokenAddress": "0x912ce59144191c1204e64559fe8253a0e49e6548",
          "amount": "2148465421470024202",
          "recipientAddress": "0xCa9d43184Cc3179609f77D38433D363833C73c60"
        }
      ]
    }'

    Output — 201 response

    {
      "operationId": "ugtp_operation_E6SnXDoAl9AQ",
      "userOperation": {
        "sender": "0x9b5fcd6f647a14ebf0f88b1c3e5a3d34b951fc6f",
        "nonce": "0x2",
        "initCode": "0x",
        "callData": "0xb61d27f6000000000000000000000000912ce59144191c…228 bytes",
        "accountGasLimits": "0x000000000000000000000000000084b800000000000000000000000000014393",
        "preVerificationGas": "0xc208",
        "gasFees": "0x00000000000000000000000000000000000000000000000000000000016eafe0",
        "paymasterAndData": "0x49f26881b5ea4d3f3d451c7faa4bca1b4aebcd97…214 bytes",
        "signature": "0x"
      },
      "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
      "ugtpFee": {
        "tokenAmount": "622595557466841530",
        "feeToken": "0x912ce59144191c1204e64559fe8253a0e49e6548"
      },
      "accountType": "eoa_7702",
      "authorizationRequired": false
    }

    Every status this call can answer with
    StatusWhen
    201Prepared operation
    400Invalid input, an unsupported network, or a self-funded call that cannot execute. Inspect the response code and reason.
    401Missing or invalid credentials
    404The referenced accountId does not exist.
    409A duplicate user operation (same UserOp hash already materialized), or code=SENDER_OPERATION_IN_FLIGHT on the self-funded rail — the account has a native transfer in flight at a nonce other than the one this prepare would hand out
    422Blocked by a security verdict, insufficient native balance, or deterministic execution failure. Inspect security or the error code before signing.
    429Rate limit exceeded — retry after the number of seconds in the `Retry-After` response header
    500An unclassified failure, carrying code=TRANSFER_PREPARE_FAILED.
    503SELF_FUNDED_ESTIMATE_UNAVAILABLE: the operation could not be measured. Retry preparation with bounded backoff.
  2. 02

    Submit what the account signed

    POST /transaction/submit

    Send the signature with operationId and attach authorization when the current prepare requests it. A self-funded transfer instead sends signedTransaction and receives its operationId on submit.

    Input — the request

    curl -X POST "https://api.ugtp.io/v1/transaction/submit" \
      -H "X-API-Key: $UGTP_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
      "operationId": "ugtp_operation_E6SnXDoAl9AQ",
      "signature": "0xa3087507768f541a4eaa9d46b581c351d29c3137…65 bytes"
    }'

    Output — 200 response

    {
      "operationId": "ugtp_operation_E6SnXDoAl9AQ",
      "status": "queued",
      "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
      "networkId": "42161"
    }

    Every status this call can answer with
    StatusWhen
    200Submission result
    400Invalid submit body or signed transaction. Inspect code for the rejected transaction field, chain or rail.
    401Missing or invalid credentials
    404Operation not found or expired — prepare it again
    409An existing operation, transaction or in-flight submission conflicts with this request. Inspect code before retrying; do not assume every 409 means success.
    429Rate limit exceeded — retry after the number of seconds in the `Retry-After` response header
    500Internal server error
    502SIGNED_TRANSACTION_IDENTITY_MISMATCH or SELF_FUNDED_BROADCAST_REFUSED. Re-sending identical bytes is permitted; this does not authorize signing a new transaction.
    503SELF_FUNDED_CHAIN_UNAVAILABLE or SELF_FUNDED_RECORD_UNAVAILABLE. Retry the same signed bytes with bounded backoff.
  3. 03

    Poll the operation

    GET /transaction/:id

    Poll by operationId, not by any hash. transactionHash can change when the outer transaction is replaced; the operation id never does.

    Input — the request

    curl -X GET "https://api.ugtp.io/v1/transaction/:id" \
      -H "X-API-Key: $UGTP_API_KEY"

    Output — 200 response

    {
      "operationId": "ugtp_operation_E6SnXDoAl9AQ",
      "status": "finalized",
      "transactionHash": "0x5977159b21734798f90fad1e442216dccec579168c00354268085a6e1d608af0",
      "userOperationHash": "0x1a4fece09e742c04398006ebdae6d03227aa7e822ba825e0c64b44fc78d12cb3",
      "networkId": "42161",
      "replacementCount": 0,
      "reason": null
    }

    Every status this call can answer with
    StatusWhen
    200Operation status
    401Missing or invalid credentials
    404Resource not found
    429Rate limit exceeded — retry after the number of seconds in the `Retry-After` response header
    500Internal server error