Skip to main content

Overview

Earn is exposed as a single product called Auto-Earn. You don’t allocate balances per strategy — you flip a per-yield-source switch for the user and Payward picks strategies and routes eligible balances. Payward absorbs any on-chain bonding or unbonding periods, so allocated assets stay liquid and remain at the user’s disposal at all times. Today the only yield source is staking; more may be added without breaking the response shape. The integration shape is six endpoints:
GoalEndpointMethod
Discover Auto-Earn eligibility by country/v1/earn/auto/assetsGET
Discover Auto-Earn eligibility for an account/v1/accounts/{account_id}/earn/auto/assetsGET
Set Auto-Earn preferences (per yield source)/v1/accounts/{account_id}/earn/autoPUT
Read current preferences (incl. pending state)/v1/accounts/{account_id}/earn/autoGET
List the user’s current per-asset allocations/v1/accounts/{account_id}/earn/auto/allocationsGET
List historical rewards & next payout estimate/v1/accounts/{account_id}/earn/auto/rewardsGET
If you have used Kraken’s Spot REST Earn endpoints (Strategies, Allocations, Allocate, Deallocate), the PWS Earn API is a higher-level abstraction of the same product. PWS picks strategies, routes eligible balances, and hides any on-chain bonding or unbonding so allocated assets remain liquid for the user. You see the result through the six endpoints above and through the Portfolio API, which surfaces each settled reward as an earn_reward transaction.

Prerequisites

  • Payward Services API credentials (see the Authentication guide).
  • A verified user with at least one account and sufficient balance in the source asset.
  • Examples target https://api.services.payward.com and read credentials from the PWS_API_KEY and PWS_API_SECRET environment variables.

Workflow

1

Discover Auto-Earn assets (optional, pre-account)

Show what’s available in a country before you have an account. User-agnostic.GET /v1/earn/auto/assets?country=US
2

Discover Auto-Earn assets for an account

Show APY and per-user allocation cap for the account’s eligible assets.GET /v1/accounts/{account_id}/earn/auto/assets
3

Set Auto-Earn preferences

Enable or disable a yield source. Async.PUT /v1/accounts/{account_id}/earn/auto
4

Poll until preferences settle

Read current preferences and watch pending_enabled / pending_disabled resolve.GET /v1/accounts/{account_id}/earn/auto
5

Read allocations and rewards

Show how much is currently earning, and historical / upcoming rewards.
  • GET /v1/accounts/{account_id}/earn/auto/allocations
  • GET /v1/accounts/{account_id}/earn/auto/rewards

Auto-Earn preference lifecycle

Each yield source has one of four states. Only the yield sources the user is eligible for appear in GET /v1/accounts/{account_id}/earn/auto; missing fields mean the user can’t enable that source today and the corresponding field on PUT is a no-op. There is no separate eligibility endpoint — presence in this response is the eligibility check.
StateMeaningWhat to show
enabledYield source is active. Eligible balances are earning.”On”.
disabledYield source is off. No new allocations.”Off”.
pending_enabledPUT accepted; provisioning in progress.”Enabling…” with a spinner. Don’t allow re-toggling.
pending_disabledPUT accepted; unallocation in progress.”Disabling…” with a spinner. Don’t allow re-toggling.
PUT /v1/accounts/{account_id}/earn/auto returns immediately with an empty body. Poll GET /v1/accounts/{account_id}/earn/auto until the pending state resolves.
Only one Auto-Earn preference change can be in progress per user at a time. Submitting a PUT while any yield source is in pending_enabled or pending_disabled returns 409 Conflict. Wait for the pending state to resolve in GET /v1/accounts/{account_id}/earn/auto before issuing the next change.

Asset, amount, and quote-currency conventions

Allocation and reward entries describe an asset at the top level using three fields:
FieldDescription
symbolTicker symbol (e.g. ETH, EUR).
typefiat, crypto, stablecoin, or xstock.
nameHuman-readable name (e.g. "Ethereum").
All monetary fields inside an entry are plain decimal strings:
SuffixDenominated in
(none)The entry’s own asset. allocated: "100.1234" means 100.1234 ETH on an ETH entry.
_in_quoteThe request’s quote currency. allocated_in_quote: "12345.12" is 12,345.12 EUR when quote_symbol=EUR.
The allocations and rewards endpoints accept quote_symbol and quote_type to choose the quote asset, and echo both at the top level of the response so you can format *_in_quote values without re-stating the choice. Today only quote_type=fiat is supported; quote_symbol defaults to USD. Pass the same values across Earn and the Portfolio API so totals reconcile.

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
Every PWS write endpoint (POST / PUT / DELETE) accepts an Idempotency-Key header containing a UUIDv4. Generate a fresh key per logical attempt — replays of the same key return the original response body and the Idempotent-Replayed: true response header, which keeps retries safe under timeouts and connection drops.
The Earn endpoints either accept query parameters or a JSON body. Include the query string in the path you sign — the helper supports this through its params argument.

Step 1: discover Auto-Earn assets by country

GET /v1/earn/auto/assets is user-agnostic. Use it to surface the value proposition before sign-up — for example on a landing page filtered by the visitor’s country. Pagination is via page_token / page_size.
def list_auto_earn_assets_by_country(country, page_size=20, page_token=None):
    endpoint = "/v1/earn/auto/assets"
    nonce = time.time_ns()
    params = {"country": country, "page_size": str(page_size)}
    if page_token:
        params["page_token"] = page_token

    signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params=params)
    response = requests.get(
        BASE_URL + endpoint,
        headers=pws_headers(signature, nonce),
        params=params,
    )
    return response.json()


catalog = list_auto_earn_assets_by_country("US")
for asset in catalog["data"]:
    print(f"{asset['symbol']} ({asset['name']}): {asset['apy']}% APY, cap {asset['user_cap']} {asset['symbol']}")

Response example

{
  "data": [
    {
      "symbol": "ETH",
      "type": "crypto",
      "name": "Ethereum",
      "apy": "3.41",
      "user_cap": "1000"
    }
  ],
  "next_page_token": null
}
FieldHow to use it
apyEstimated annual percentage yield as a decimal string (e.g. "3.41" for 3.41%).
user_capHard maximum a single user is allowed to auto-earn for this asset, in the entry’s asset. Show as a “max” hint.
next_page_tokenOpaque cursor; pass back as page_token to fetch the next page.
APY is an estimate, not a guarantee. Display the value with a % suffix and label it “estimated”, since realized yield depends on network conditions and fees.

Step 2: discover Auto-Earn assets for an account

GET /v1/accounts/{account_id}/earn/auto/assets returns the same shape as Step 1 but scoped to a specific account. Use it after sign-up to confirm exactly what that account can auto-earn.
def list_account_auto_earn_assets(account_id, page_size=20, page_token=None):
    endpoint = f"/v1/accounts/{account_id}/earn/auto/assets"
    nonce = time.time_ns()
    params = {"page_size": str(page_size)}
    if page_token:
        params["page_token"] = page_token

    signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params=params)
    response = requests.get(
        BASE_URL + endpoint,
        headers=pws_headers(signature, nonce),
        params=params,
    )
    return response.json()


account_id = "PW43ACME0000000000000001"
account_assets = list_account_auto_earn_assets(account_id)
for asset in account_assets["data"]:
    print(f"{asset['symbol']}: {asset['apy']}% APY (cap {asset['user_cap']} {asset['symbol']})")

Response example

{
  "data": [
    {
      "symbol": "ETH",
      "type": "crypto",
      "name": "Ethereum",
      "apy": "3.41",
      "user_cap": "1000"
    }
  ],
  "next_page_token": null
}

Step 3: set Auto-Earn preferences

Send a PUT to /v1/accounts/{account_id}/earn/auto with the desired state of each yield source you want to change. All body fields are optional — yield sources you omit keep their current preference, so you can flip a single source without touching the others. Supported yield sources today: staking. Allowed values: "enabled", "disabled".
def set_auto_earn_preferences(account_id, preferences):
    endpoint = f"/v1/accounts/{account_id}/earn/auto"
    nonce = time.time_ns()

    signature = get_payward_signature(endpoint, preferences, API_SECRET, nonce)
    response = requests.put(
        BASE_URL + endpoint,
        headers=pws_headers(signature, nonce, idempotent=True),
        json=preferences,
    )
    return response.status_code, response.json()


status, body = set_auto_earn_preferences(account_id, {"staking": "enabled"})
print(f"Toggle accepted: HTTP {status}")

Response example

{
  "data": {}
}
The 200 OK confirms the preference change was accepted, not that every eligible balance is already earning. Provisioning runs asynchronously; observe pending_enabled / pending_disabled on GET /v1/accounts/{account_id} /earn/auto to know when the change has settled. A second PUT issued before the pending state resolves returns 409 Conflict — see the preference lifecycle above.

Step 4: poll until preferences settle

GET /v1/accounts/{account_id}/earn/auto returns the current preference for every yield source the user is eligible for. Eligibility is country-aware: if a user moves to a region where staking isn’t supported, the staking field disappears from this response and the corresponding field on PUT becomes a no-op.
def get_auto_earn_preferences(account_id):
    endpoint = f"/v1/accounts/{account_id}/earn/auto"
    nonce = time.time_ns()

    signature = get_payward_signature(endpoint, None, API_SECRET, nonce)
    response = requests.get(BASE_URL + endpoint, headers=pws_headers(signature, nonce))
    return response.json()


def wait_for_settled(account_id, source, max_attempts=60):
    pending = {"pending_enabled", "pending_disabled"}
    for _ in range(max_attempts):
        prefs = get_auto_earn_preferences(account_id)
        state = prefs.get(source)
        print(f"{source}: {state}")
        if state is None or state not in pending:
            return prefs
        time.sleep(2)
    raise RuntimeError(f"{source} did not settle in time")


prefs = wait_for_settled(account_id, "staking")

Response example

{
  "staking": "enabled"
}

Step 5a: list current allocations

GET /v1/accounts/{account_id}/earn/auto/allocations returns the user’s current per-asset allocation, plus the all-asset total in the requested quote currency. The endpoint is cursor-paginated; next_page_token is absent on the last page. All amounts inside entries are plain decimal strings; the response echoes quote_symbol / quote_type at the top level so you know how to format *_in_quote values.
def list_auto_earn_allocations(account_id, quote_symbol="USD", page_token=None, page_size=20):
    endpoint = f"/v1/accounts/{account_id}/earn/auto/allocations"
    nonce = time.time_ns()
    params = {
        "quote_symbol": quote_symbol,
        "quote_type": "fiat",
        "page_size": str(page_size),
    }
    if page_token:
        params["page_token"] = page_token

    signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params=params)
    response = requests.get(
        BASE_URL + endpoint,
        headers=pws_headers(signature, nonce),
        params=params,
    )
    return response.json()


allocations = list_auto_earn_allocations(account_id, quote_symbol="EUR")
quote = allocations["quote_symbol"]
print(f"Total allocated: {allocations['total_allocated_in_quote']} {quote}")
for entry in allocations["data"]:
    print(
        f"  {entry['allocated']} {entry['symbol']} "
        f"(~{entry['allocated_in_quote']} {quote})"
    )

Response example

{
  "quote_symbol": "EUR",
  "quote_type": "fiat",
  "total_allocated_in_quote": "12345.12",
  "data": [
    {
      "symbol": "ETH",
      "type": "crypto",
      "name": "Ethereum",
      "allocated": "100.1234",
      "allocated_in_quote": "12345.12"
    }
  ],
  "next_page_token": null
}
FieldHow to use it
quote_symbol / quote_typeEcho of the requested quote asset. Use to format *_in_quote values.
total_allocated_in_quoteCross-asset allocation total in the quote currency. Use it for a dashboard hero value.
data[].allocatedPer-asset amount currently earning, in the entry’s asset.
data[].allocated_in_quoteSame per-asset amount in the quote currency.
next_page_tokenOpaque cursor; pass back as page_token for the next page.
Auto-Earn allocations are liquid. The allocated amount is the user’s current earning balance and is always at the user’s disposal — PWS absorbs any on-chain bonding or unbonding so there are no “in-transit” amounts for you to track on the Earn side.

Step 5b: list rewards

GET /v1/accounts/{account_id}/earn/auto/rewards returns historical rewards per asset, the all-time total in the quote currency, and the next payout estimate. Same quote_symbol / quote_type / page_token / page_size semantics as allocations, and the same envelope (quote_symbol / quote_type echoed at the top level). estimated_next_reward, estimated_next_reward_in_quote, and the top-level next_reward_date are absent when no reward is pending. other_assets is absent on an entry when every reward for that asset was paid in the entry’s own asset.
def list_auto_earn_rewards(account_id, quote_symbol="USD", page_token=None, page_size=20):
    endpoint = f"/v1/accounts/{account_id}/earn/auto/rewards"
    nonce = time.time_ns()
    params = {
        "quote_symbol": quote_symbol,
        "quote_type": "fiat",
        "page_size": str(page_size),
    }
    if page_token:
        params["page_token"] = page_token

    signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params=params)
    response = requests.get(
        BASE_URL + endpoint,
        headers=pws_headers(signature, nonce),
        params=params,
    )
    return response.json()


rewards = list_auto_earn_rewards(account_id, quote_symbol="EUR")
quote = rewards["quote_symbol"]
print(f"Total rewarded: {rewards['total_rewarded_in_quote']} {quote}")
if rewards.get("next_reward_date"):
    print(f"Next payout: {rewards['next_reward_date']}")

for entry in rewards["data"]:
    line = f"  {entry['symbol']}: rewarded {entry['rewarded']} {entry['symbol']}"
    if "estimated_next_reward" in entry:
        line += f", next ~{entry['estimated_next_reward']} {entry['symbol']}"
    print(line)
    for side in entry.get("other_assets", []):
        print(
            f"    + {side['rewarded']} {side['symbol']} "
            f"(~{side['rewarded_in_quote']} {quote})"
        )

Response example

{
  "quote_symbol": "EUR",
  "quote_type": "fiat",
  "total_rewarded_in_quote": "325262.124",
  "next_reward_date": "2026-05-07T16:57:51Z",
  "data": [
    {
      "symbol": "ETH",
      "type": "crypto",
      "name": "Ethereum",
      "rewarded": "13.5",
      "rewarded_in_quote": "2451.12",
      "estimated_next_reward": "0.56",
      "estimated_next_reward_in_quote": "101.23",
      "other_assets": [
        {
          "symbol": "EIGEN",
          "type": "crypto",
          "name": "EigenLayer",
          "rewarded": "5.0",
          "rewarded_in_quote": "123.45",
          "estimated_next_reward": "0.1",
          "estimated_next_reward_in_quote": "2.34"
        }
      ]
    }
  ],
  "next_page_token": null
}
FieldHow to use it
quote_symbol / quote_typeEcho of the requested quote asset. Use to format *_in_quote values.
total_rewarded_in_quoteAll-time, cross-asset rewards in the quote currency. Headline figure for the rewards card.
next_reward_dateTimestamp of the next payout across all assets. Absent when nothing is pending.
data[].rewarded / _in_quotePer-asset all-time rewards, in the entry’s asset and in the quote currency.
data[].estimated_next_reward / _in_quoteBest-effort estimate of the upcoming reward for that asset. Absent when no payout is pending.
data[].other_assetsRewards paid in a different asset than the entry’s own (e.g. restaking-style side rewards). Same shape minus a nested other_assets. Absent when all rewards are in the entry’s asset.
next_page_tokenOpaque cursor; pass back as page_token for the next page.
Reward amounts can be very small for low-APY assets — a value like "0.00000001" is common. Decide up front how to render near-zero values (for example, “<0.00000001 ETH” or “~0”).

Reconciling rewards in Portfolio

When a payout settles, it appears in the Portfolio activity feed as an earn_reward transaction:
curl -X GET "https://api.services.payward.com/v1/accounts/PW43ACME0000000000000001/portfolio/transactions?types=earn_reward&from_time=2026-04-01T00:00:00Z&until_time=2026-04-30T23:59:59Z&quote_symbol=USD" \
  -H "API-Key: $PWS_API_KEY" \
  -H "API-Sign: $PWS_API_SIGN"
Use the Portfolio guide for the activity feed and pagination, and the Reports endpoints for downloadable settlement statements.

Error handling

HTTP statusCauseRemediation
400 Bad RequestInvalid country (not ISO 3166-1 alpha-2), unsupported quote_type, bad enum value in the PUT body, or bad pagination tokensValidate query parameters and body before sending.
401 UnauthorizedMissing or invalid signature, key, or nonceVerify API-Key, API-Sign, and API-Nonce. The nonce must increase.
403 ForbiddenAccount is not authorized to use EarnConfirm the account’s product entitlements.
404 Not FoundUnknown account_idVerify the account_id belongs to your partner.
409 ConflictAn Auto-Earn preference change is already in progress for this user — only one may be in flight at a timePoll GET /v1/accounts/{account_id}/earn/auto until no yield source is in pending_enabled / pending_disabled, then retry.
429 Too Many RequestsRate limit hitHonor Retry-After and back off exponentially.

Best practices

  1. Treat the PUT as eventually consistent. A 200 OK confirms the change was accepted, not that balances are already earning.
  2. Only one preference change in flight per user. Wait for the pending state to resolve before submitting another change; otherwise the second PUT returns 409 Conflict.
  3. Only send the yield sources you actually want to change. Omit the others to keep their preferences untouched.
  4. Pass quote_symbol consistently across allocations, rewards, and Portfolio so converted totals line up across views.
  5. Don’t assume APY is fixed. Refetch the asset list whenever you display APY; it drifts with network conditions and country availability.
  6. Capture Request-Id from response headers. It speeds up support investigations.

API reference

EndpointMethodDescription
/v1/earn/auto/assetsGETList Auto-Earn assets eligible in a country (user-agnostic). Required country query parameter.
/v1/accounts/{account_id}/earn/auto/assetsGETList Auto-Earn assets available to the given account, with APY and per-user cap.
/v1/accounts/{account_id}/earn/autoPUTSet Auto-Earn preferences per yield source. Async; empty data: {} on success.
/v1/accounts/{account_id}/earn/autoGETGet current Auto-Earn preferences. Includes pending_enabled / pending_disabled while a PUT is being applied.
/v1/accounts/{account_id}/earn/auto/allocationsGETList the user’s current per-asset allocations and the cross-asset total in the requested quote currency.
/v1/accounts/{account_id}/earn/auto/rewardsGETList historical rewards per asset, the all-time total in the quote currency, and the next payout estimate.