Swagger

Transactions

Prepare, sign, submit, and poll a gasless 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.

Two ways to prepare, and they are not interchangeable

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. POST /transaction/prepare takes your own call list and builds it on the UserOperation rail only. Prefer transfer for moving assets: it is the one that reaches every network.

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

Which mechanism carries an operation follows from the network and the asset, and the response tells you by the presence of rail. That is the one branch your client needs; everything after it — submit, poll, read the fee — is the same code on every rail.

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

The prepared operation is held server-side for a limited time (30 minutes by default). Submit after that and it is a 404: nothing is wrong, the snapshot is simply gone, so prepare again rather than retrying the submit. A duplicate of an operation already materialized comes back 409 — read it as already booked, not as a failure.

An account's first operation can need one extra field

On the networks that delegate, prepare answers authorizationRequired and authorizationNonce for the first operation of an account that has not adopted the delegate yet — attach the EIP-7702 authorization on submit. It is never asked for again, and the rails that delegate nothing refuse the field with a 400.

Each of these ran. The request, the response, and the object that went on chain are the operation's own, read back out of the platform's records — and the transaction link opens the result in a block explorer, so every number here can be checked against something outside this page.

A gasless ERC-20 transfer

UserOperation rail (EIP-7702 account) · Arbitrum One

2.148465421470024202 ARB moved to 0xCa9d43184Cc3179609f77D38433D363833C73c60. The account held no ETH and paid nothing for gas.

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",
  "security": {
    "verdict": "safe",
    "reason": null,
    "provider": "checkpoint"
  },
  "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. Nothing can relay a native balance on an account's behalf, so the account 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 gasless transfer, 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",
      "security": {
        "verdict": "safe",
        "reason": null,
        "provider": "checkpoint"
      },
      "ugtpFee": {
        "tokenAmount": "622595557466841530",
        "feeToken": "0x912ce59144191c1204e64559fe8253a0e49e6548"
      },
      "accountType": "eoa_7702",
      "authorizationRequired": false
    }

    Every status this call can answer with
    StatusWhen
    201Prepared operation
    400Invalid request body, the referenced account belongs to a different network than the request targets, or (7702-mode projects only) EIP-7702 is not…
    401Missing or invalid credentials
    404The referenced accountId does not exist.
    409Duplicate user operation (same UserOp hash already materialized)
    422Unprocessable — either blocked by an unsafe security verdict (returns the prepared operation; enforcement is monitor-only by default), or the…
    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 native rail only, and always retryable — nothing is known to be wrong: code=SELF_FUNDED_ESTIMATE_UNAVAILABLE (the transfer could not be…
  2. 02

    Submit what the account signed

    POST /transaction/submit

    Send the signature with the operationId from the prepare. On the first operation of an unactivated account, attach the EIP-7702 authorization the prepare asked for — and never on a later one.

    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 body, or — on the self-funded native rail — bytes this rail cannot carry: code=SIGNED_TRANSACTION_UNSUPPORTED_TYPE (not EIP-1559),…
    401Missing or invalid credentials
    404Operation not found or expired — prepare it again
    409This user operation was already submitted under a different operation id; or the operation was already submitted (status mismatch); or, on the…
    429Rate limit exceeded — retry after the number of seconds in the `Retry-After` response header
    500Internal server error
    502code=SIGNED_TRANSACTION_IDENTITY_MISMATCH — the engine derived a different transaction hash from the same bytes, so nothing was recorded and the…
    503Self-funded native rail only, retryable: code=SELF_FUNDED_CHAIN_UNAVAILABLE (the chain could not be checked for whether these bytes already landed —…
  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