Skip to main content

Prerequisites

  • Payward Services API credentials (see the Authentication guide).
  • A verified user with at least one account and on-chain quote trading enabled.
  • A source wallet registered on the user’s withdrawal-address list as a same-owner address. The address must be approved before you create a quote.
  • Enough source-asset balance in the wallet to settle the on-chain quote.

Workflow

1

Create an on-chain quote

Create a wallet-funded xStocks on-chain quote and receive the on-chain payload.POST /v1/accounts/{account_id}/onchain-quotes
2

Submit the on-chain transaction

Call the Payward on-chain proxy contract with the returned payload before expires_at.executeSwap(...)
3

Poll for terminal status

Wait for Payward Services to observe settlement, or subscribe to webhooks.GET /v1/accounts/{account_id}/onchain-quotes/{quote_id}

Quote lifecycle

StatusDescription
pendingQuote is being prepared. Transitions to offered once the signed payload is ready, or to failed if preparation fails.
offeredQuote created and locked. Valid until expires_at.
executingThe on-chain transaction was observed and is awaiting confirmation.
executedTerminal. The on-chain quote completed.
expiredTerminal. expires_at elapsed before settlement.
failedTerminal. The on-chain transaction reverted, or the offer could not be completed.

Specifying the trade

Onchain quotes are restricted to xStock and USDC pairs. Set the wallet block on from — the quote is funded from the user’s wallet, not from the user’s Payward Services account balance.
FieldRequiredDescription
from.walletyes{ address, network }. Must be an approved same-owner withdrawal address.
from.symbolyesSource asset symbol. Use USDC when buying xStocks, or the xStock when selling.
from.typeyesstablecoin or xstock.
to.symbolyesDestination asset symbol.
to.typeyesstablecoin or xstock.
from.amount or to.amountone ofSet exactly one. The server calculates the other side.
The only supported network value is ethereum.

Authentication setup

Authenticated endpoints require an HMAC-SHA512 request signature in the API-Sign header and a monotonically increasing nonce in the API-Nonce header. The helper below derives the signature from the URL path, request body, and nonce. See the Authentication guide for the full algorithm.
import os
import json
import time
import uuid
import hashlib
import hmac
import base64
import urllib.parse
import requests

API_KEY = os.environ["PWS_API_KEY"]
API_SECRET = os.environ["PWS_API_SECRET"]
BASE_URL = "https://api.services.payward.com"


def get_payward_signature(urlpath, data, secret, nonce, params=None):
    encoded = (
        str(nonce).encode("utf-8")
        if data is None
        else (str(nonce) + json.dumps(data)).encode("utf-8")
    )

    sign_path = urlpath
    if params:
        sign_path += "?" + urllib.parse.urlencode(params)

    message = sign_path.encode() + hashlib.sha256(encoded).digest()
    mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512)
    return base64.b64encode(mac.digest()).decode()


def pws_headers(signature, nonce, idempotent=False):
    headers = {
        "API-Key": API_KEY,
        "API-Sign": signature,
        "API-Nonce": str(nonce),
        "Content-Type": "application/json",
    }
    if idempotent:
        headers["Idempotency-Key"] = str(uuid.uuid4())
    return headers

Step 1: create an on-chain quote

Send a POST to /v1/accounts/{account_id}/onchain-quotes with from.wallet populated. The create response returns the full quote including execution_details, which carries the proxy contract address and the signed on-chain payload.
Store execution_details from the create response. GET /v1/accounts/{account_id}/onchain-quotes/{quote_id} returns quote status and amounts, but it does not return a fresh on-chain execution payload.

Step 2: submit the on-chain transaction

Call executeSwap on execution_details.proxy_address. Pass the returned proxy_swap_message before the quote expires. The proxy call uses this argument shape:
Typescript
const proxyAbi = [
  {
    type: 'function',
    name: 'executeSwap',
    stateMutability: 'nonpayable',
    outputs: [],
    inputs: [
      {
        name: 'proxySwapMessage',
        type: 'tuple',
        components: [
          {
            name: 'atomicMessage',
            type: 'tuple',
            components: [
              { name: 'quoteId', type: 'bytes32' },
              { name: 'expiration', type: 'uint256' },
              {
                name: 'incomingTransfer',
                type: 'tuple',
                components: [
                  { name: 'from', type: 'address' },
                  { name: 'to', type: 'address' },
                  { name: 'token', type: 'address' },
                  { name: 'amount', type: 'uint256' },
                ],
              },
              {
                name: 'outgoingTransfer',
                type: 'tuple',
                components: [
                  { name: 'from', type: 'address' },
                  { name: 'to', type: 'address' },
                  { name: 'token', type: 'address' },
                  { name: 'amount', type: 'uint256' },
                ],
              },
            ],
          },
          { name: 'atomicSignature', type: 'bytes' },
          {
            name: 'inputTokenPermit',
            type: 'tuple',
            components: [
              { name: 'token', type: 'address' },
              { name: 'value', type: 'uint256' },
              { name: 'v', type: 'uint8' },
              { name: 'r', type: 'bytes32' },
              { name: 's', type: 'bytes32' },
            ],
          },
          { name: 'signature', type: 'bytes' },
        ],
      },
    ],
  },
] as const;

const zeroPermit = {
  token: '0x0000000000000000000000000000000000000000',
  value: 0n,
  v: 0,
  r: '0x0000000000000000000000000000000000000000000000000000000000000000',
  s: '0x0000000000000000000000000000000000000000000000000000000000000000',
};

function tokenTransfer(transfer) {
  return {
    from: transfer.from,
    to: transfer.to,
    token: transfer.token,
    amount: BigInt(transfer.amount),
  };
}

const execution = quote.data.execution_details;
const message = execution.proxy_swap_message;
const atomic = message.atomic_message;

const proxyMessage = {
  atomicMessage: {
    quoteId: atomic.quote_id,
    expiration: BigInt(atomic.expiration),
    incomingTransfer: tokenTransfer(atomic.incoming_transfer),
    outgoingTransfer: tokenTransfer(atomic.outgoing_transfer),
  },
  atomicSignature: message.atomic_signature,
  inputTokenPermit: message.input_token_permit ?? zeroPermit,
  signature: message.signature,
};

await walletClient.writeContract({
  address: execution.proxy_address,
  abi: proxyAbi,
  functionName: 'executeSwap',
  args: [proxyMessage],
});
If you do not pass an input-token permit, approve the proxy to spend the source token from the user’s wallet before you broadcast the transaction.
Onchain quote settlement happens on-chain. There is no server-side execute endpoint — the proxy contract call is the only way to fulfil an on-chain quote offer.

Step 3: poll for terminal status

After broadcasting on-chain, poll GET /v1/accounts/{account_id}/onchain-quotes/{quote_id} until status reaches a terminal value (executed, expired, or failed).
Subscribe to the quote.executed and quote.execution_failed webhooks if you do not want to poll.

Reviewing on-chain quotes

List previously created on-chain quotes with GET /v1/accounts/{account_id}/onchain-quotes. Results are returned newest-first. Use page_token and page_size (defaults to 50, max 200) for pagination — next_page_token is omitted on the final page.
History entries omit execution_details. The signed on-chain payload is only returned by the create response.

Error handling

HTTP statusCodeCauseRemediation
400 Bad Requestvalidation errorfrom.wallet.address is not an approved same-owner address.Have the user add the wallet in the Payward UI and wait for approval.
400 Bad Requestvalidation errorBoth or neither of from.amount / to.amount were set.Set exactly one side of the trade.
400 Bad Requestvalidation errorfrom/to pair is not an xStock/USDC trade.Restrict trades to xStock ↔ stablecoin pairs.
403 ForbiddenforbiddenThe user is not eligible for on-chain quote trading.Confirm the user’s eligibility with your account manager.
410 Gonequote_expiredexpires_at elapsed before settlement.Request a fresh quote and broadcast more quickly.
503 Service Unavailableinsufficient_liquidityNo liquidity is available at the requested size.Retry with a smaller size. If it persists, contact support.

Best practices

  1. Register wallets ahead of time. Screening can take time and will block quote creation until the address is approved.
  2. Store the on-chain quote create response. It is the only response that includes the signed on-chain execution payload.
  3. Treat executed, expired, and failed as terminal. Request a new quote for another attempt.
  4. Submit on-chain well before expires_at. Gas spikes and confirmation time reduce the usable window.
  5. Capture Request-Id from response headers. It speeds up support investigations.

API reference

EndpointMethodDescription
/v1/accounts/{account_id}/onchain-quotesPOSTCreate a wallet-funded on-chain quote. Returns the quote with execution_details.
/v1/accounts/{account_id}/onchain-quotesGETList on-chain quotes for the authenticated user (paginated, newest first).
/v1/accounts/{account_id}/onchain-quotes/{quote_id}GETRead on-chain quote status and amounts. Does not return a new execution payload.