# Get Asset Source: https://docs.services.payward.com/api-reference/assets/get-asset /api-reference/openapi_v3_pws.json get /v1/assets/{asset_type}/{asset_symbol} Get information about a specific asset. # Get User Asset Source: https://docs.services.payward.com/api-reference/assets/get-user-asset /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/assets/{asset_type}/{asset_symbol} Get information about a specific asset. # List Asset Rates Source: https://docs.services.payward.com/api-reference/assets/list-asset-rates /api-reference/openapi_v3_pws.json get /v1/assets/{asset_type}/{asset_symbol}/rates Returns historical rates for a given asset. The response includes a timestamp and price for each interval in the requested time window. Use `quote_symbol` and `quote_type` to choose the asset used for pricing. # List Assets Source: https://docs.services.payward.com/api-reference/assets/list-assets /api-reference/openapi_v3_pws.json get /v1/assets List assets available on the platform. The response includes each asset's symbol, type, display metadata, market data, and per-action availability. Use the optional filters to narrow the list by asset type or by whether an asset is enabled for swap trading, withdrawals, or deposits. # List User Asset Rates Source: https://docs.services.payward.com/api-reference/assets/list-user-asset-rates /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/assets/{asset_type}/{asset_symbol}/rates Returns historical rates for a given asset. The response includes a timestamp and price for each interval in the requested time window. Use `quote_symbol` and `quote_type` to choose the asset used for pricing. # List User Assets Source: https://docs.services.payward.com/api-reference/assets/list-user-assets /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/assets List assets available on the platform. The response includes each asset's symbol, type, display metadata, market data, and per-action availability. Use the optional filters to narrow the list by asset type or by whether an asset is enabled for swap trading, withdrawals, or deposits. # Create bank link Source: https://docs.services.payward.com/api-reference/bank-links/create-bank-link /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/bank-links Creates a Bank Link — a connection between this account and one banking institution — for a funding method that requires linking. Returns it in `pending` with a hosted-widget URL; send the user there to choose their institution and approve access. Every bank account the user shares becomes an Account Link under this Bank Link once it activates. `method_id` must be a method this account can currently use. Discover methods with `GET /v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}`. Any other value is rejected with `bank_link_method_not_linkable` rather than creating a link that cannot complete. Provider identifiers, access tokens, owner names, and raw bank numbers are never returned. Reference the link and its accounts only by `bank_link_id` and `account_link_id`. # Get bank link Source: https://docs.services.payward.com/api-reference/bank-links/get-bank-link /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/bank-links/{bank_link_id} Retrieves a Bank Link and its nested active Account Links. Unlinked links are not returned. Eligibility runs fresh on every read rather than being stored. Ownership validation is part of it: the bank account's holder must match this account's verified holder. An account that fails carries `ineligibility_reasons` such as `name_mismatch` and reports `ineligible`; an account that could not be evaluated carries none and reports `unknown`. # List bank links Source: https://docs.services.payward.com/api-reference/bank-links/list-bank-links /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/bank-links Lists the account's Bank Links with their nested active Account Links, ordered by link time descending. Links that are still being completed and unlinked links are not returned. Results are paged: follow `next_page_token` until it is absent. Eligibility is computed fresh on every read, so a link's rails and verdict can change between pages. # Reconnect bank link Source: https://docs.services.payward.com/api-reference/bank-links/reconnect-bank-link /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}/reconnect Reconnects a Bank Link whose provider connection expired or was revoked. Returns it in `pending` with a fresh hosted-widget URL; the user re-authorizes through the provider, and a stored credential is never reused. Completing the reconnect reconciles the account set. Accounts the user shares again keep their `account_link_id`. Newly shared accounts appear as new Account Links. Accounts no longer shared become `removed`, which drops them from get and list. An `account_link_id` is therefore stable but revocable, not permanent. # Unlink bank link Source: https://docs.services.payward.com/api-reference/bank-links/unlink-bank-link /api-reference/openapi_v3_pws.json delete /v1/accounts/{account_id}/funds/bank-links/{bank_link_id} Severs the whole Bank Link: the link becomes `unlinked` and every Account Link is soft-removed. Unlinked links disappear from get and list and are rejected for new money movement; Account Link ids remain referenceable in history. Unlinking is permanent. Linking the same institution or bank account again later creates a new Bank Link with a new `bank_link_id`, and each shared account gets a new `account_link_id`. Old ids are never reused. Repeating the call returns the same final snapshot. Unlinking a link that never finished activating cancels its pending session and returns that same `unlinked` snapshot. A link busy with an in-flight provider operation (activation, reconnect, or an earlier unlink attempt) returns `Unavailable`; retry shortly. Unlinking also severs the provider-side connection. If the provider refuses — for example, transactions are still settling — the link keeps its current status and the call returns `bank_link_provider_rejected`. If the provider outcome is unknown — for example, the provider was unreachable — the call returns `Unavailable` and the link stays busy until the pending attempt expires; retry after that to finish the unlink. # Create conversion rule Source: https://docs.services.payward.com/api-reference/conversion-rules/create-conversion-rule /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/conversions Creates a new conversion rule. Provisions the inbound deposit endpoint (IBAN for fiat-source, crypto address for crypto-source) the caller will fund. Once funded, transactions are produced against this conversion rule and become observable via the transactions surface. # Delete conversion rule Source: https://docs.services.payward.com/api-reference/conversion-rules/delete-conversion-rule /api-reference/openapi_v3_pws.json delete /v1/accounts/{account_id}/conversions/{conversion_rule_id} Deletes a conversion rule (soft-delete). Inbound funds already received continue to settle; future deposits are rejected. Re-deleting an already-deleted conversion rule returns `404 NotFound` — partners cannot distinguish "deleted" from "never existed". # Get conversion rule Source: https://docs.services.payward.com/api-reference/conversion-rules/get-conversion-rule /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/conversions/{conversion_rule_id} Retrieves the current state of a previously created conversion rule. # List conversion rule transactions Source: https://docs.services.payward.com/api-reference/conversion-rules/list-conversion-rule-transactions /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/conversions/{conversion_rule_id}/transactions Lists transactions produced by a single conversion rule, ordered by `created_at` descending. Each conversion rule accumulates zero-or-more transactions over its lifetime as deposits arrive and trades execute. Use `ListConversionRules` to enumerate the rules themselves. # List conversion rules Source: https://docs.services.payward.com/api-reference/conversion-rules/list-conversion-rules /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/conversions Lists conversion rules for the caller, ordered by `created_at` descending. # Update conversion rule Source: https://docs.services.payward.com/api-reference/conversion-rules/update-conversion-rule /api-reference/openapi_v3_pws.json put /v1/accounts/{account_id}/conversions/{conversion_rule_id} Updates an existing conversion rule. Replaces the destination, name, and status in a single full-state PUT (matching the autoramp rule update contract). The source side is immutable — re-create the conversion rule if you need to change input asset, rail, or wallet. # Get account auto earn assets Source: https://docs.services.payward.com/api-reference/earn/get-account-auto-earn-assets /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/earn/auto/assets Returns auto-earn asset info for assets available to the specified account. Results are paginated. # Get auto earn allocations Source: https://docs.services.payward.com/api-reference/earn/get-auto-earn-allocations /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/earn/auto/allocations Returns current allocation info for the specified account. Results are paginated. Converted amounts are denominated in the currency identified by `quote_symbol` / `quote_type` (defaults to USD/fiat). # Get auto earn assets Source: https://docs.services.payward.com/api-reference/earn/get-auto-earn-assets /api-reference/openapi_v3_pws.json get /v1/earn/auto/assets Returns auto-earn asset info for a given country. User-agnostic endpoint. Results are paginated. `country` is required and must be a valid ISO 3166-1 alpha-2 country code. # Get auto earn preferences Source: https://docs.services.payward.com/api-reference/earn/get-auto-earn-preferences /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/earn/auto Returns the current auto-earn preferences for the specified account. A yield source key is only present in the response if the account is eligible for that yield source. Absent keys indicate ineligibility. # Get auto earn rewards Source: https://docs.services.payward.com/api-reference/earn/get-auto-earn-rewards /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/earn/auto/rewards Returns historical reward info for the specified account. Results are paginated. Converted amounts are denominated in the currency identified by `quote_symbol` / `quote_type` (defaults to USD/fiat). `estimated_next_reward` and `next_reward_date` are absent if no reward is pending. # Set auto earn preferences Source: https://docs.services.payward.com/api-reference/earn/set-auto-earn-preferences /api-reference/openapi_v3_pws.json patch /v1/accounts/{account_id}/earn/auto Sets auto-earn preferences for the specified account. All body fields are optional. Only fields that are present will be updated; omitted yield sources are left unchanged. # Activate deposit method Source: https://docs.services.payward.com/api-reference/funds/activate-deposit-method /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/deposits/methods/{method_id}/activation Requests activation of a deposit method for the selected account. Use a method identifier returned by the deposit-methods endpoint whose `activation_status` is `needed`. This operation sends one activation request to Funding. It does not claim a deposit address or return deposit instructions. A successful response means Funding accepted the activation request; activation may be completed immediately or remain pending. Poll the deposit-methods endpoint until `activation_status` is `completed`, then retrieve the method's deposit instructions separately. Activation returns `400 Bad Request` when the selected account is missing verification information or documents required by the funding method. The error is associated with `account_id`; when available, its message names the missing requirement, such as `address`. If the activation request times out or its outcome is unclear, list the deposit methods again before taking further action. Do not retry if the activation status is `pending` or `completed`. # Calculate funding fees Source: https://docs.services.payward.com/api-reference/funds/calculate-funding-fees /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/fees Calculates the fee for funding a gross amount through one selected method, deposit or withdrawal. Use a method `id` from [List deposit methods](/api-reference/funds/list-deposit-methods) or [List withdrawal methods](/api-reference/funds/list-withdrawal-methods). `amount` is inclusive of the fee. The response reports `gross_amount` (the submitted amount), `fee`, and `net_amount` (the amount left after fees). For same-asset crypto fees, `net_amount + fee` equals `gross_amount`. `fee_token` is returned only for methods that produce one, in practice withdrawal methods. When present, supply it to [Create withdrawal](/api-reference/funds/create-withdrawal) to reuse this quote's fee terms. Each request returns a new quote and moves no funds. # Claim deposit address Source: https://docs.services.payward.com/api-reference/funds/claim-deposit-address /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/deposits/addresses Claims a crypto deposit address for a funding method available to the selected account. BTC over Lightning uses a per-payment invoice instead of a reusable deposit address. Use [Create Lightning invoice](/api-reference/funds/create-lightning-invoice). This operation is not idempotent. If a request times out or otherwise has an unknown outcome, do not automatically retry it because an address may already have been claimed. First list the account's claimed deposit addresses or contact Payward Services support. Compatible funding methods can share address limits. List and reuse an existing address before claiming another. When the shared limit has been reached and no existing address is eligible to expire, the request returns `409` with code `deposit_address_limit_reached`. # Create funding address Source: https://docs.services.payward.com/api-reference/funds/create-funding-address /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/addresses Registers a user-added cryptocurrency funding address for the selected account. Set `scope` to exactly one `method_id` or `network_id` returned by the withdrawal-methods endpoint. Supply the crypto destination fields required by that method. The address is validated when it is created. This operation is not idempotent. If a request times out or otherwise has an unknown outcome, list the account's funding addresses before creating another one. # Create Lightning invoice Source: https://docs.services.payward.com/api-reference/funds/create-lightning-invoice /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/deposits/lightning-invoices Creates a single-use BOLT11 payment request for depositing an exact amount to the selected account over the Bitcoin Lightning Network. Each successful request creates a new invoice. The invoice must be paid in full, in a single payment, before `expires_at`. A paid invoice cannot be reused, and payments to an expired invoice are rejected by the network. Use the deposit-methods endpoint to discover the Lightning deposit limits that apply to the account; amounts outside those limits are rejected with `400`. This operation is not idempotent. If a request times out or otherwise has an unknown outcome, create a new invoice instead of reusing the earlier response; unpaid invoices expire on their own. Completed deposits appear in the account's Portfolio transactions and are announced by `transaction.deposit` webhook events when a webhook subscription is configured. # Create transfer Source: https://docs.services.payward.com/api-reference/funds/create-transfer /api-reference/openapi_v3_pws.json post /v1/transfers Creates a transfer between accounts in the authenticated caller's program. If the conversion rate becomes stale before the transfer is created, the operation returns `409` with code `stale_conversion_rate`. Obtain a fresh rate before submitting a new transfer request. The returned `transfer_id` is the originating reference for the corresponding Portfolio transaction. Once that transaction is visible, retrieve it with `GET /v1/accounts/{account_id}/portfolio/transactions` using `reference.kind=transfer` and `reference.id` equal to this `transfer_id`. Use the `from` account from this request as `account_id` when retrieving the initiating account's transaction. Transfer creation and Portfolio transaction visibility are asynchronous boundaries. A response with `status=complete` confirms the create operation's current outcome, but the corresponding Portfolio transaction may not be visible immediately. Discover it by polling Portfolio with the transfer type and an appropriate time range; the Portfolio transaction can first have `status=in_progress` before reaching `successful` or `failed`. `Idempotency-Key` is optional to preserve compatibility with existing clients. When omitted, the operation remains non-idempotent: if a request times out or otherwise has an unknown outcome, do not automatically retry it because another transfer may be created. First reconcile the source account's Portfolio transactions or contact Payward Services support. When provided, `Idempotency-Key` must be a UUIDv4. Reusing the same key with the same request prevents a second transfer execution and returns the transfer's current recorded outcome. The response can differ from the original response and does not include `Idempotent-Replayed`. After the original transfer request has been recorded, reusing the same key with different, otherwise valid transfer parameters returns `409 Conflict`. Each retry is validated before its idempotency key is compared with the recorded request. Consequently, a retry containing invalid parameters, such as an unsupported asset, returns the corresponding validation error instead of `409 Conflict`. A request rejected before it is recorded may leave the key available for another request. Recorded transfer keys remain reserved beyond 24 hours and must not be reused for a new transfer. This is an exception to the general PWS idempotency standard. # Create withdrawal Source: https://docs.services.payward.com/api-reference/funds/create-withdrawal /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/funds/withdrawals Creates a cryptocurrency withdrawal from the selected account to a registered funding address. Optionally supply `fee_token` from [Calculate funding fees](/api-reference/funds/calculate-funding-fees) to reuse a quote's fee terms. To safely retry a request with an unknown outcome, supply a UUIDv4 `Idempotency-Key` and reuse the same key for the retry. The header is optional. If you omit it, each request is treated as a new withdrawal request. # Delete funding address Source: https://docs.services.payward.com/api-reference/funds/delete-funding-address /api-reference/openapi_v3_pws.json delete /v1/accounts/{account_id}/funds/addresses/{address_id} Permanently deletes a user-added funding address for the selected account. # Get funding address Source: https://docs.services.payward.com/api-reference/funds/get-funding-address /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/addresses/{address_id} Returns one user-added cryptocurrency funding address registered for the selected account. # List deposit addresses Source: https://docs.services.payward.com/api-reference/funds/list-deposit-addresses /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/deposits/addresses Lists crypto and fiat deposit addresses available for use with a funding method on the selected account. Compatible funding methods can share an address space, so the same address may be returned for more than one asset or network. The `method_id` selects a context in which the returned addresses are valid; it does not identify the method through which an address was originally claimed. Static and claimed addresses are returned in that order. A page token is valid only with the same account and funding method used to obtain it. # List deposit methods Source: https://docs.services.payward.com/api-reference/funds/list-deposit-methods /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol} Returns the crypto and fiat deposit methods available to the selected account for an asset. Use `method` to distinguish options on the same network. For crypto methods, use each method's stable `id` as `method_id` when claiming or listing crypto deposit addresses. Those deposit-address operations do not support fiat methods. For fiat methods, `supports_virtual_accounts` reports whether Funding can provide dedicated virtual-account instructions. A `false` value does not identify a particular alternative flow; the later deposit-instructions response determines whether static bank instructions and a payment reference are available. `activation_status` reports whether Funding requires activation before the method can be used; request activation through the deposit-method activation endpoint when the status is `needed`. `maximum` combines the method's per-transaction cap with the account's remaining supported amount-based rolling limits. Supported count-based limits are listed separately in `period_limits`. These values are informational and may change before a deposit is processed. # List funding addresses Source: https://docs.services.payward.com/api-reference/funds/list-funding-addresses /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/addresses Lists user-added cryptocurrency funding addresses available to the selected account. Claimed deposit addresses remain under the deposit-address endpoint and are not returned here. The list contains withdrawal-direction funding addresses. On the first request, omit `page_token` and optionally set `page_size` and `method_id`. To continue, send the returned `page_token` and the same `method_id` when filtering. Omit `page_size`; the token preserves it. The token is bound to the selected account and method filter. # List withdrawal methods Source: https://docs.services.payward.com/api-reference/funds/list-withdrawal-methods /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/funds/withdrawals/methods/{asset_symbol} Returns the cryptocurrency withdrawal methods available to the selected account for an asset. Use `method` to distinguish options on the same network, and use each method's stable `id` as `method_id` when registering a funding address. `address_fields` lists the crypto destination fields to collect for that method. `maximum` combines the method's per-transaction cap with the account's remaining supported amount-based rolling limits. Supported count-based limits are listed separately in `period_limits`. These values are informational and may change before a withdrawal is processed. # Update funding address Source: https://docs.services.payward.com/api-reference/funds/update-funding-address /api-reference/openapi_v3_pws.json patch /v1/accounts/{account_id}/funds/addresses/{address_id} Renames a user-added funding address for the selected account. # Create on-chain quote Source: https://docs.services.payward.com/api-reference/on-chain-swaps/create-on-chain-quote /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/onchain-quotes Creates a new wallet-funded onchain quote. The amount field must appear on exactly one side (from or to). Whichever side carries the amount is the fixed side; the server calculates the other. The source wallet is required because onchain quote execution is submitted directly by the client on-chain. # Get on-chain quote Source: https://docs.services.payward.com/api-reference/on-chain-swaps/get-on-chain-quote /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/onchain-quotes/{quote_id} Retrieves the current state of a previously created onchain quote. # List on-chain quotes Source: https://docs.services.payward.com/api-reference/on-chain-swaps/list-on-chain-quotes /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/onchain-quotes Lists onchain quotes for the authenticated user. # Create on-demand conversion Source: https://docs.services.payward.com/api-reference/on-demand-conversions/create-on-demand-conversion /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/on-demand-conversions Creates an on-demand conversion for the selected account. The service calculates the missing amount and processes the conversion in the background. # Get on-demand conversion Source: https://docs.services.payward.com/api-reference/on-demand-conversions/get-on-demand-conversion /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/on-demand-conversions/{conversion_id} Returns an on-demand conversion and its current status. # List on-demand conversions Source: https://docs.services.payward.com/api-reference/on-demand-conversions/list-on-demand-conversions /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/on-demand-conversions Lists on-demand conversions for the selected account, ordered by `created_at` descending. # Get portfolio Source: https://docs.services.payward.com/api-reference/portfolio/get-portfolio /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/portfolio Returns one account portfolio snapshot containing account-level values and the per-asset breakdown. Quote-denominated monetary amounts use an `_in_quote` suffix; native asset amounts omit the suffix. # List portfolio history Source: https://docs.services.payward.com/api-reference/portfolio/list-portfolio-history /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/portfolio/history Returns daily portfolio valuation snapshots for an account over the requested time window. Use `assets` to include per-asset balances for selected holdings. # List portfolio transactions Source: https://docs.services.payward.com/api-reference/portfolio/list-portfolio-transactions /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/portfolio/transactions Lists account portfolio transactions with optional filters for type, asset, status, transaction ID, originating reference, time range, and sort direction. Results are cursor-paginated. Results are ordered newest first by default; set `sort_direction=asc` for oldest first. Page tokens are valid for up to 24 hours. Transactions can appear asynchronously. To discover new activity, poll this operation with the applicable `types` filters and an appropriate time range, omitting `statuses` or including `in_progress` while a transaction is being processed. For a transfer created by `POST /v1/transfers`, use `types=transfer` and the `from` account from the create request. The `transfer_id` returned by create is the `id` in a `reference` with `kind=transfer`. Once the transaction appears, that reference can be submitted in `references` for deterministic subsequent lookup. A transfer may appear with `status=in_progress` before it reaches the terminal `successful` or `failed` status; filtering only by `successful` can therefore hide a newly materialized transfer. The first Portfolio response after `POST /v1/transfers` may contain no matching transaction even when the create response has `status=complete`; continue polling until the transaction is present. For deposits, `reference.kind=funding` identifies the materialized funding transaction. It is not the deposit destination or a payment reference from bank-transfer instructions; those values describe how to make the payment and are not accepted by `references`. Discover a new deposit by polling with the applicable type, asset, and time filters. Once the transaction appears, its Funding reference can be used for deterministic subsequent lookup. The returned transaction `id` uses the `TX` namespace and is distinct from the reference identifier. Successful `swap` and `price_trigger_swap` transactions include the executed quote identifier in `details.quote_id`. Failed swap attempts are returned with `status=failed` and include a type-specific `details.failure_reason`. # Cancel price trigger swap Source: https://docs.services.payward.com/api-reference/price-trigger-swaps/cancel-price-trigger-swap /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}/cancel Cancels a price trigger swap that is still in `active` status. Only swaps in `active` status can be cancelled. Swaps in `completed` or `cancelled` status are terminal and cannot be cancelled — those return `409 Conflict` with `code: price_trigger_swap_not_cancellable`. On success, the swap's `status` becomes `cancelled` and `cancelled_reason` is populated with `code: user_request`. Subscribers to the `price_trigger_swap.cancelled` webhook receive a notification. # Create price trigger swap Source: https://docs.services.payward.com/api-reference/price-trigger-swaps/create-price-trigger-swap /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/price-trigger-swaps Submits a new price trigger swap. The swap remains in `active` status until its `when` condition is met or it is cancelled. When the condition fires, the configured `trade` is executed. On successful execution the swap transitions to `completed` and a `price_trigger_swap.executed` webhook is delivered; the realized `trade.fees` and `trade.rate` are then populated on the swap. If the trade fails, a `price_trigger_swap.execution_failed` webhook is delivered and the swap may be retried; once retries are exhausted the swap is moved to `cancelled` with `reason: "system"`. Reusing a `client_order_id` that is already associated with another price trigger swap for the same account returns `409 Conflict` with `code: price_trigger_swap_client_order_id_duplicate`. Use `POST /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}/cancel` to cancel an `active` swap, and `GET /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}` to poll for status. # Get price trigger swap Source: https://docs.services.payward.com/api-reference/price-trigger-swaps/get-price-trigger-swap /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id} Returns the current state of a single price trigger swap by ID. Use this to poll a swap's `status` after creation, after a webhook delivery, or to inspect the trigger and action that were configured. The response includes the swap's `cancelled_reason` when `status` is `cancelled`. # List price trigger swaps Source: https://docs.services.payward.com/api-reference/price-trigger-swaps/list-price-trigger-swaps /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/price-trigger-swaps Returns a cursor-paginated list of price trigger swaps for the user, optionally filtered by status. Results are ordered by `created_at` descending (newest first). Pagination uses opaque `page_token` values that are price-triggered swap ids; `next_page_token` is omitted on the final page. To refine results, pass one or more `statuses` query values; omitting the filter returns swaps in any status. # Create Ramp checkout session Source: https://docs.services.payward.com/api-reference/ramp/create-ramp-checkout-session /api-reference/openapi_v3_pws.json post /v1/users/{user_id}/ramp/checkouts Creates a Ramp checkout for a selected user and returns the widget redirect URL with the checkout UUID in the fragment. No access token is minted here; the widget mints it via the bootstrap call (`BootstrapRampCheckout`). The URL must still be treated as sensitive — the UUID is a capability handle. # Get Ramp Checkout URL Source: https://docs.services.payward.com/api-reference/ramp/get-ramp-checkout-url /api-reference/openapi_v3_pws.json get /v1/ramp/checkout Generate a hosted Ramp checkout URL for the provided transaction configuration. The response echoes the request parameters so the Ramp partner can confirm what was submitted. # Get Ramp Limits Source: https://docs.services.payward.com/api-reference/ramp/get-ramp-limits /api-reference/openapi_v3_pws.json get /v1/ramp/limits Retrieve combined min/max limits for a Ramp transaction configuration. # Get Ramp Prospective Quote Source: https://docs.services.payward.com/api-reference/ramp/get-ramp-prospective-quote /api-reference/openapi_v3_pws.json get /v1/ramp/quotes/prospective Retrieve a prospective quote for a Ramp transaction without reserving liquidity. Use this to preview spend/receive amounts before creating a checkout URL. # List Ramp Buy Cryptocurrency Assets Source: https://docs.services.payward.com/api-reference/ramp/list-ramp-buy-cryptocurrency-assets /api-reference/openapi_v3_pws.json get /v1/ramp/buy/crypto List cryptocurrency assets supported for Ramp buy transactions, including networks and withdrawal methods. # List Ramp Countries Source: https://docs.services.payward.com/api-reference/ramp/list-ramp-countries /api-reference/openapi_v3_pws.json get /v1/ramp/countries List countries and subdivision groups supported for Ramp deposits. # List Ramp Fiat Currencies Source: https://docs.services.payward.com/api-reference/ramp/list-ramp-fiat-currencies /api-reference/openapi_v3_pws.json get /v1/ramp/fiat-currencies List fiat currencies supported for funding Ramp transactions. # List Ramp Payment Methods Source: https://docs.services.payward.com/api-reference/ramp/list-ramp-payment-methods /api-reference/openapi_v3_pws.json get /v1/ramp/payment-methods List fiat funding methods supported for Ramp deposits. # Get Settlement Report Source: https://docs.services.payward.com/api-reference/reports/get-settlement-report /api-reference/openapi_v3_pws.json get /v1/reports/settlement/{id} Retrieves a settlement report and provides a secure download URL for the authenticated Master User. # List Settlement Reports Source: https://docs.services.payward.com/api-reference/reports/list-settlement-reports /api-reference/openapi_v3_pws.json get /v1/reports/settlement Retrieves a paginated list of settlement reports available to the authenticated Master User, with optional filters for date range and report type. # Create quote Source: https://docs.services.payward.com/api-reference/swaps/create-quote /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/quotes Creates an account-funded swap quote that locks a price for ~2 minutes. Account-funded swap quotes must be executed before `expires_at` via `POST /v1/accounts/{account_id}/quotes/{quote_id}/execute`, otherwise they transition to `expired` and are no longer usable. Exactly one of `from.amount` or `to.amount` must be set; the server calculates the other side. Setting both or neither is rejected with `400 Bad Request`. # Execute quote Source: https://docs.services.payward.com/api-reference/swaps/execute-quote /api-reference/openapi_v3_pws.json post /v1/accounts/{account_id}/quotes/{quote_id}/execute Triggers execution of a pending account-funded swap quote. Preconditions: the quote must be in `offered` status and must not have passed its `expires_at` timestamp. Quotes are valid for ~2 minutes after creation; execution after expiry returns `410 Gone`. Execution is asynchronous. The response confirms the quote entered `executing` status; poll `GET /v1/accounts/{account_id}/quotes/{quote_id}` to observe the terminal state (`executed` or `failed`). # Get quote Source: https://docs.services.payward.com/api-reference/swaps/get-quote /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/quotes/{quote_id} Retrieves the current state of a previously created quote. Use this to poll the lifecycle of a quote you submitted via `POST /v1/accounts/{account_id}/quotes` or executed via `POST /v1/accounts/{account_id}/quotes/{quote_id}/execute`. Quote `status` transitions are: `offered` -> `executing` -> `executed` (terminal). A quote can also transition to `expired` (TTL elapsed before execution) or `failed` (executor rejected). # Get quote limits Source: https://docs.services.payward.com/api-reference/swaps/get-quote-limits /api-reference/openapi_v3_pws.json get /v1/accounts/{account_id}/quotes/limits Returns the effective amount limits for a swap on the selected account. Limits reflect current liquidity and trading constraints. They are a point-in-time estimate and can change before a quote is created. When available, `limit_reasons` explains which constraints contributed to `maximum`. Set `amount_side` to the side whose amount the partner will fix when creating a quote. The returned asset and amounts are denominated in that side's asset. Clamp create-quote amounts to `[minimum_swap, maximum]`. Amount-too-large/small errors apply to create quote, not to this discovery call. Dust-only floors are intentionally not exposed here and may be added later for dust-sweep flows. # Create verification Source: https://docs.services.payward.com/api-reference/travel-rule-verifications/create-verification /api-reference/openapi_v3_pws.json post /v1/users/{user_id}/travel-rule/verifications Creates a Travel Rule verification case for a known wallet address. Use this endpoint when the wallet address is known before a specific product flow needs to continue. # Get verification Source: https://docs.services.payward.com/api-reference/travel-rule-verifications/get-verification /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/travel-rule/verifications/{verification_id} Gets a previously returned proactive Travel Rule verification case. # Create User Source: https://docs.services.payward.com/api-reference/users/create-user /api-reference/openapi_v3_pws.json post /v1/users Create a new user in the Payward system. When retrying user creation, send the same `external_reference` for the same partner user. Payward uses this value to avoid creating duplicate users. Retry responses are generated from the latest available user state, not replayed from the original request. Returns the created `id` and the `accounts` associated with the user. # Create User Account Source: https://docs.services.payward.com/api-reference/users/create-user-account /api-reference/openapi_v3_pws.json post /v1/users/{user_id}/accounts Create an additional account for an existing user. Every user is created with one account. Use this endpoint to add further accounts — for example to separate a user's savings from their day-to-day balance. `name` is required; `description` and `notes` are optional and stored verbatim for your own reference. # Get User Source: https://docs.services.payward.com/api-reference/users/get-user /api-reference/openapi_v3_pws.json get /v1/users/{user_id} Retrieve a previously created user by their user ID. Returns the user's core resource — its canonical `id`, the partner-supplied `external_reference`, the `created_at` timestamp, and the `accounts` associated with the user. Common use cases: - Confirm a user exists and retrieve its account IDs before issuing account-scoped calls. - Read the partner-supplied reference associated with a user. To retrieve onboarding state and pending actions, use **Get Verification Requirements**. # Create Hosted Verification Source: https://docs.services.payward.com/api-reference/verifications/create-hosted-verification /api-reference/openapi_v3_pws.json post /v1/users/{user_id}/verifications/hosted Create or reuse the hosted verification required for the user. PWS derives the provider, level, and verification type from the current KYC requirement. The result contains one or more hosted methods, or `not_required` when the user has no actionable requirement. # Get verification Source: https://docs.services.payward.com/api-reference/verifications/get-verification /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/verifications/{verification_id} Get a verification summary for the given user and verification ID. # Get Verification Requirements Source: https://docs.services.payward.com/api-reference/verifications/get-verification-requirements /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/verifications/requirements Get the user's verification state and ordered required actions. The `user_action_required` field is true only when the user or partner can complete an action now. A `wait` action is non-actionable. # List verifications Source: https://docs.services.payward.com/api-reference/verifications/list-verifications /api-reference/openapi_v3_pws.json get /v1/users/{user_id}/verifications List verification summaries for the given user. # Conversion deposit completed Source: https://docs.services.payward.com/api-reference/webhooks/conversion-deposit-completed /api-reference/openapi_v3_pws.json webhook conversion.deposit_completed Inbound funds were credited for a conversion transaction. # Conversion deposit failed Source: https://docs.services.payward.com/api-reference/webhooks/conversion-deposit-failed /api-reference/openapi_v3_pws.json webhook conversion.deposit_failed The inbound funding leg failed for a conversion transaction. # Conversion deposit held Source: https://docs.services.payward.com/api-reference/webhooks/conversion-deposit-held /api-reference/openapi_v3_pws.json webhook conversion.deposit_held The inbound funding leg is held pending review for a conversion transaction. # Conversion quote execution failed Source: https://docs.services.payward.com/api-reference/webhooks/conversion-quote-execution-failed /api-reference/openapi_v3_pws.json webhook conversion.quote_execution_failed Quote creation or execution failed for a pre-quote conversion transaction. # Conversion travel rule blocked Source: https://docs.services.payward.com/api-reference/webhooks/conversion-travel-rule-blocked /api-reference/openapi_v3_pws.json webhook conversion.travel_rule_blocked A pre-trade Travel Rule check blocked the conversion before any quote execution was attempted. Either the destination wallet requires an ownership verification that has not been satisfied, or the check could not be evaluated because mandatory information is missing; the specific cause is in `error_code`. # Conversion withdrawal completed Source: https://docs.services.payward.com/api-reference/webhooks/conversion-withdrawal-completed /api-reference/openapi_v3_pws.json webhook conversion.withdrawal_completed Outbound settlement completed for a conversion transaction. # Conversion withdrawal failed Source: https://docs.services.payward.com/api-reference/webhooks/conversion-withdrawal-failed /api-reference/openapi_v3_pws.json webhook conversion.withdrawal_failed The outbound settlement leg failed for a conversion transaction. # Delete Webhook Source: https://docs.services.payward.com/api-reference/webhooks/delete-webhook /api-reference/openapi_v3_pws.json delete /v1/webhooks/{webhook_id} Delete a webhook configuration. This permanently removes the webhook and all its event subscriptions. No further events will be delivered to the webhook URL. **Note:** This action cannot be undone. If you want to temporarily stop receiving events, consider using the **Update Webhook** endpoint to disable the webhook instead. # Funds deposit credited Source: https://docs.services.payward.com/api-reference/webhooks/funds-deposit-credited /api-reference/openapi_v3_pws.json webhook funds.deposit_credited A deposit was credited and can now be used. # Funds deposit failed Source: https://docs.services.payward.com/api-reference/webhooks/funds-deposit-failed /api-reference/openapi_v3_pws.json webhook funds.deposit_failed A deposit failed and will not be credited. # Funds deposit held Source: https://docs.services.payward.com/api-reference/webhooks/funds-deposit-held /api-reference/openapi_v3_pws.json webhook funds.deposit_held A deposit is held pending review or additional checks. # Funds deposit received Source: https://docs.services.payward.com/api-reference/webhooks/funds-deposit-received /api-reference/openapi_v3_pws.json webhook funds.deposit_received A deposit was received and is awaiting confirmations or checks. # Funds deposit returned Source: https://docs.services.payward.com/api-reference/webhooks/funds-deposit-returned /api-reference/openapi_v3_pws.json webhook funds.deposit_returned A deposit was returned to its source. # Get Webhook Source: https://docs.services.payward.com/api-reference/webhooks/get-webhook /api-reference/openapi_v3_pws.json get /v1/webhooks/{webhook_id} Retrieves a webhook by id. # List Webhook Attempts Source: https://docs.services.payward.com/api-reference/webhooks/list-webhook-attempts /api-reference/openapi_v3_pws.json get /v1/webhooks/{webhook_id}/attempts Lists delivery attempts made to this webhook, most recent first. A delivery attempt is one HTTP POST to the webhook's destination URL. Because deliveries are retried on failure, a single emitted event can produce several attempts against the same webhook: the first is `scheduled`, and each subsequent retry is another `scheduled` attempt until the event is delivered or the retry schedule is exhausted; a partner-initiated resend produces a `manual` attempt. Every attempt is listed, so a successful delivery and the failed tries that preceded it all appear as separate entries sharing the same `message_id`. # List Webhooks Source: https://docs.services.payward.com/api-reference/webhooks/list-webhooks /api-reference/openapi_v3_pws.json get /v1/webhooks Retrieve all webhooks registered for your domain. This endpoint returns each webhook configuration, including the event types it is subscribed to. **Note:** The webhook `signing_secret` is NOT included in this response. The secret is only returned once during registration and cannot be retrieved again. # Portfolio transaction deposit Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-deposit /api-reference/openapi_v3_pws.json webhook transaction.deposit A Portfolio deposit transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Portfolio transaction earn reward Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-earn-reward /api-reference/openapi_v3_pws.json webhook transaction.earn_reward A Portfolio earn reward transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Portfolio transaction price trigger swap Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-price-trigger-swap /api-reference/openapi_v3_pws.json webhook transaction.price_trigger_swap A Portfolio price trigger swap transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Portfolio transaction swap Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-swap /api-reference/openapi_v3_pws.json webhook transaction.swap A Portfolio swap transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Portfolio transaction transfer Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-transfer /api-reference/openapi_v3_pws.json webhook transaction.transfer A Portfolio transfer transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Portfolio transaction withdrawal Source: https://docs.services.payward.com/api-reference/webhooks/portfolio-transaction-withdrawal /api-reference/openapi_v3_pws.json webhook transaction.withdrawal A Portfolio withdrawal transaction was created or updated. Use the Portfolio Transactions endpoint to retrieve the complete ledger entry. # Quote cancelled Source: https://docs.services.payward.com/api-reference/webhooks/quote-cancelled /api-reference/openapi_v3_pws.json webhook quote.cancelled Quote cancelled before execution. # Quote executed Source: https://docs.services.payward.com/api-reference/webhooks/quote-executed /api-reference/openapi_v3_pws.json webhook quote.executed Quote successfully executed against the partner account. # Quote execution failed Source: https://docs.services.payward.com/api-reference/webhooks/quote-execution-failed /api-reference/openapi_v3_pws.json webhook quote.execution_failed Quote execution attempted but failed. # Register Webhook Source: https://docs.services.payward.com/api-reference/webhooks/register-webhook /api-reference/openapi_v3_pws.json post /v1/webhooks Register a new webhook endpoint to receive event notifications from Payward. When you register a webhook, you'll receive a `signing_secret` in the response. This secret is used to verify webhook signatures. Store it securely — it is only returned once and cannot be retrieved again. Before registering a webhook, make sure your callback domain has been allowlisted. You must specify at least one event when registering a webhook. You can optionally set `disabled` to create the webhook without sending events immediately. ## URL Requirements - Must use HTTPS protocol - Maximum length: 2048 characters - Must be a valid URL format - Must have a valid host (not empty) - Host cannot be a protocol name (e.g., `http`, `https`, `ftp`, `file`, `mailto`, `ssh`, `git`, `svn`) - Path cannot start with `//` (indicates malformed URL) - Cannot contain credentials (e.g., `https://user:pass@example.com` is rejected) ### Valid URL Examples - `https://example.com/webhook` - `https://example.com:8443/webhook` ### Invalid URL Examples - `http://example.com/webhook` (HTTP not allowed) - `https://https//example.com` (malformed - host is "https") - `https://user:pass@example.com/webhook` (credentials not allowed) - `invalid-url` (not a valid URL) # Rotate Webhook Secret Source: https://docs.services.payward.com/api-reference/webhooks/rotate-webhook-secret /api-reference/openapi_v3_pws.json post /v1/webhooks/{webhook_id}/secret/rotate Rotates the webhook's signing secret and returns the new value. The previous secret is invalidated; Svix continues to also sign with the old secret for a short grace period so partners can roll the new value out without dropped deliveries. The new signing secret is returned in plaintext exactly once — partners must persist it and use it to verify the signature on inbound webhook deliveries. # Test Webhook Source: https://docs.services.payward.com/api-reference/webhooks/test-webhook /api-reference/openapi_v3_pws.json post /v1/webhooks/{webhook_id}/test Send a test event to verify your webhook configuration. This endpoint sends a representative event to your configured webhook URL, allowing you to verify that: - Your endpoint is reachable - Your signature verification is working correctly - Your endpoint responds appropriately The webhook must be subscribed to the requested event. A successful response means the test event was accepted for delivery. # Update Webhook Source: https://docs.services.payward.com/api-reference/webhooks/update-webhook /api-reference/openapi_v3_pws.json patch /v1/webhooks/{webhook_id} Update a webhook configuration. Use this endpoint to: - Update the webhook URL or description - Disable or re-enable a webhook without deleting it - Update which events the webhook is subscribed to Disabled webhooks will not receive any events until re-enabled. # User closed Source: https://docs.services.payward.com/api-reference/webhooks/user-closed /api-reference/openapi_v3_pws.json webhook user.closed The user's account was closed. # User disabled Source: https://docs.services.payward.com/api-reference/webhooks/user-disabled /api-reference/openapi_v3_pws.json webhook user.disabled The user's account became disabled. # Verification requirements updated Source: https://docs.services.payward.com/api-reference/webhooks/verification-requirements-updated /api-reference/openapi_v3_pws.json webhook verification.requirements_updated The user's verification requirements changed. # Verification updated Source: https://docs.services.payward.com/api-reference/webhooks/verification-updated /api-reference/openapi_v3_pws.json webhook verification.updated A verification's status changed. # Payward Services Source: https://docs.services.payward.com/index Built on 15 years of Kraken operations, Payward Services provides one integration replacing fragmented multi-vendor stacks with a single point for trading, payments, tokenized assets, staking, lending, compliance, and settlement. Payward Services ## Platform capabilities Programmable payment rails with stablecoin settlement Global on/off ramps across fiat currencies and digital assets Spot, perpetuals, futures, and tokenized asset markets with deep liquidity Yield-generating products with institutional-grade risk controls Coordinated KYC/AML, risk management, and secure asset custody Always-on settlement across digital assets and fiat with unified ledger ## API specification Save the machine-readable OpenAPI (Swagger) spec as JSON to generate clients or import the API into your tooling. Prefer to explore it in your browser? Open the [API reference](/api-reference). # Portfolio Source: https://docs.services.payward.com/tabs/developer-documentation/accounts/portfolio Use the Portfolio API to show account value, asset positions, transaction history, and historical portfolio performance. ## Overview The Portfolio API gives you the account-level view after users deposit, withdraw, trade, transfer funds, or earn rewards. Use it to show current portfolio value, per-asset balances, an activity feed, and historical performance. Subscribe to Portfolio transaction webhooks when you want change notifications without continuous polling. All Portfolio endpoints are scoped to a specific `account_id`. Pass the same `quote_symbol` and `quote_type` across the current portfolio, history, and transaction calls when you want their monetary values to use the same quote asset. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)) * A verified user with at least one account * Base URL: `https://api.services.payward.com` ## Portfolio workflow Use this flow when you build a portfolio dashboard or reconcile user activity after payments and trades. Fetch account-level values and the per-asset balance breakdown in one snapshot. `GET /v1/accounts/{account_id}/portfolio` Fetch transaction history for deposits, withdrawals, transfers, swaps, price trigger swaps, and earn rewards. `GET /v1/accounts/{account_id}/portfolio/transactions` Register for `transaction.*` webhook events. Retrieve the complete transaction from Portfolio after each notification. `POST /v1/webhooks` Fetch historical balances and valuations for a selected time window. `GET /v1/accounts/{account_id}/portfolio/history` ## Step 1: get the current portfolio Call `GET /v1/accounts/{account_id}/portfolio` to get the account totals and per-asset balances in one consistent snapshot. Set `with_zero_balances=true` when you also need assets with zero balances, such as when you display every supported asset. ```python Python theme={null} def get_portfolio( account_id, quote_symbol="USD", quote_type="fiat", with_zero_balances=False ): endpoint = f"/v1/accounts/{account_id}/portfolio" params = { "quote_symbol": quote_symbol, "quote_type": quote_type, "with_zero_balances": str(with_zero_balances).lower(), } signature = get_payward_signature(endpoint, None, API_SECRET, params) headers = { "API-Key": API_KEY, "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, params=params, ) return response.json() portfolio = get_portfolio("ABCD1234EFGH5678") print(portfolio["data"]["total_in_quote"]) for asset in portfolio["data"]["assets"]: print( asset["symbol"], asset["balances"]["total"], asset["balances"].get("total_in_quote"), ) ``` ```javascript Javascript theme={null} async function getPortfolio(accountId, quoteSymbol = 'USD', quoteType = 'fiat', withZeroBalances = false) { const endpoint = `/v1/accounts/${accountId}/portfolio`; const params = new URLSearchParams({ quote_symbol: quoteSymbol, quote_type: quoteType, with_zero_balances: String(withZeroBalances), }); const signature = getPaywardSignature(endpoint, null, API_SECRET, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Sign': signature, }, }); return response.json(); } const portfolio = await getPortfolio('ABCD1234EFGH5678'); console.log(portfolio.data.total_in_quote); for (const asset of portfolio.data.assets) { console.log(asset.symbol, asset.balances.total, asset.balances.total_in_quote); } ``` Use these account-level fields for the dashboard header: | Field | How to use it | | ---------------------------------- | ------------------------------------------------------------------------------ | | `as_of` | Show when the latest index price in the snapshot was updated. | | `quote` | Identify the quote asset used by monetary values throughout the snapshot. | | `total_in_quote` | Show total account value in the quote asset. | | `availability.available_in_quote` | Show funds available for trading or withdrawal after holds. | | `availability.withheld.*_in_quote` | Explain amounts held for open orders, earn products, or other temporary holds. | | `pnl.*_in_quote` | Show account-level average-cost-basis PnL when available. | | `assets` | Render the per-asset balance breakdown from the same snapshot. | For each item in `assets`, use these fields: | Field | How to use it | | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `symbol` and `type` | Identify the asset unambiguously. | | `index_price_in_quote` | Show the price of one native asset unit in the top-level quote asset. | | `balances.total` | Show the balance in the asset's native display units. | | `balances.total_in_quote` | Show the value of that same total in the top-level quote asset. | | `balances.main` | Show balances held in the main source, including balances used by spot trading and received from swaps. | | `held_for_orders` | Show native asset amounts held for open orders. | | `earn` | Break earn balances down by liquidity state and yield source. | | `availability` | Show native amounts available for trading or internal transfer. | | `credit_line`, `spot_margin`, and `pnl` | Add credit and explicitly quoted spot-margin and average-cost-basis details when they apply. | Quote-denominated monetary amounts end in `_in_quote`. Native asset amounts omit that suffix. For example, `balances.total` is an amount in the asset itself; it is not a price. Use `balances.total_in_quote` when you need its value in `data.quote`. The nested balance sources use the same `total` and `total_in_quote` naming. Account-level `availability` is an aggregate in `data.quote`; it does not belong inside `balances.main`. Per-asset `availability.trade` and `availability.transfer` are native amounts for those actions. The `pnl` objects are optional. A PnL status can be `in_progress` while its corresponding value is absent or not final. Numeric PnL fields ending in `_in_quote` use `data.quote`. `pnl.reference_currency` identifies Portfolio's calculation basis; it does not change the display currency. See the [Get portfolio API reference](/api-reference/portfolio/get-portfolio) for every query parameter and response field. ## Step 2: build the activity feed Call `GET /v1/accounts/{account_id}/portfolio/transactions` to list user-visible portfolio activity. Use it after a deposit, withdrawal, swap, price trigger swap, or earn reward to show what changed and support reconciliation. ```python Python theme={null} def list_portfolio_transactions(account_id, page_token=None): endpoint = f"/v1/accounts/{account_id}/portfolio/transactions" params = [ ("types", "deposit"), ("types", "withdrawal"), ("types", "swap"), ("statuses", "successful"), ("start_timestamp", "2026-04-01T00:00:00Z"), ("end_timestamp", "2026-04-30T23:59:59Z"), ("sort_direction", "desc"), ("quote_symbol", "USD"), ("quote_type", "fiat"), ("page_size", "20"), ] if page_token: params.append(("page_token", page_token)) signature = get_payward_signature(endpoint, None, API_SECRET, params) headers = { "API-Key": API_KEY, "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, params=params, ) return response.json() transactions = list_portfolio_transactions("ABCD1234EFGH5678") for transaction in transactions["data"]: print(transaction["timestamp"], transaction["type"], transaction["status"]) ``` ```javascript Javascript theme={null} async function listPortfolioTransactions(accountId, pageToken = null) { const endpoint = `/v1/accounts/${accountId}/portfolio/transactions`; const params = new URLSearchParams(); params.append('types', 'deposit'); params.append('types', 'withdrawal'); params.append('types', 'swap'); params.append('statuses', 'successful'); params.set('start_timestamp', '2026-04-01T00:00:00Z'); params.set('end_timestamp', '2026-04-30T23:59:59Z'); params.set('sort_direction', 'desc'); params.set('quote_symbol', 'USD'); params.set('quote_type', 'fiat'); params.set('page_size', '20'); if (pageToken) params.set('page_token', pageToken); const signature = getPaywardSignature(endpoint, null, API_SECRET, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Sign': signature, }, }); return response.json(); } const transactions = await listPortfolioTransactions('ABCD1234EFGH5678'); for (const transaction of transactions.data) { console.log(transaction.timestamp, transaction.type, transaction.status); } ``` Common filters: | Filter | How to use it | | ------------------------------------- | ----------------------------------------------------------------------------------------------- | | `types` | Filter by `swap`, `price_trigger_swap`, `earn_reward`, `deposit`, `withdrawal`, or `transfer`. | | `statuses` | Filter by `in_progress`, `successful`, or `failed`. | | `start_timestamp` and `end_timestamp` | Restrict results to a time window. | | `assets` | Filter by asset references using indexed deep-object syntax. | | `ids` | Look up specific portfolio transaction IDs. | | `sort_direction` | Return newest-first with `desc` or oldest-first with `asc`. | | `quote_symbol` | Add best-effort `amount_in_quote` values to spend, receive, and fee amounts. Defaults to `USD`. | | `quote_type` | Use `fiat`. Other quote types are not currently supported. | Each returned transaction includes the required fields `id`, `timestamp`, `type`, and `status`. It can include `spend`, `receive`, or both. Failed transactions can omit movement details. Transactions without a defined lifecycle status are not returned. Deposits and withdrawals can also carry `details` with `type: "funding"` and a `crypto_transaction_id` you can use to link to a block explorer. It appears once the transaction settles on a blockchain, and is omitted for fiat funding. Successful `swap` and `price_trigger_swap` transactions include `details.quote_id`. Use it to correlate the transaction with the quote that executed it. Failed swap attempts appear with `status: "failed"`. Their details use `type: "failed_swap"` or `type: "failed_price_trigger_swap"` and include `failure_reason`. The reason is one of `insufficient_funds`, `card_issue`, `user_account_issue`, or `other`. Use `next_page_token` as `page_token` to fetch more transactions. See the [List Portfolio Transactions API reference](/api-reference/portfolio/list-portfolio-transactions) for all filters and response fields. ## Step 3: subscribe to transaction changes Register a webhook to receive change notifications for the Portfolio transaction categories you use. One webhook can subscribe to any combination of these events: | Event type | Portfolio transaction category | Correlation fields when available | | -------------------------------- | ------------------------------ | -------------------------------------------- | | `transaction.swap` | `swap` | `quote_id` | | `transaction.price_trigger_swap` | `price_trigger_swap` | `quote_id` | | `transaction.earn_reward` | `earn_reward` | `strategy_id`, `reward_id` | | `transaction.deposit` | `deposit` | `funding_reference`, `crypto_transaction_id` | | `transaction.withdrawal` | `withdrawal` | `funding_reference`, `crypto_transaction_id` | | `transaction.transfer` | `transfer` | `transfer_id` | Every payload contains `type`, `transaction_id`, `user_id`, `account_id`, and `timestamp`. It can also contain `status`. Treat the webhook as a change notification. Use `transaction_id` as the `ids` filter on `GET /v1/accounts/{account_id}/portfolio/transactions` to retrieve the complete ledger entry. The same transaction can emit more than one event as its status changes. Deduplicate delivery retries with the `svix-id` header. Failed swap and price trigger swap execution attempts do not emit `transaction.*` events because they do not contain ledger movements. Use the relevant quote or price trigger swap failure event instead. See the [Register Webhook API reference](/api-reference/webhooks/register-webhook) to create a subscription and review the event payload schemas. ## Step 4: chart portfolio performance Call `GET /v1/accounts/{account_id}/portfolio/history` to draw a portfolio value chart or show how balances changed over time. ```python Python theme={null} def list_portfolio_history(account_id): endpoint = f"/v1/accounts/{account_id}/portfolio/history" params = [ ("start_timestamp", "2026-04-01T00:00:00Z"), ("end_timestamp", "2026-04-30T23:59:59Z"), ("quote_symbol", "USD"), ("quote_type", "fiat"), ("assets[0][symbol]", "BTC"), ("assets[0][type]", "crypto"), ("assets[1][symbol]", "ETH"), ("assets[1][type]", "crypto"), ] signature = get_payward_signature(endpoint, None, API_SECRET, params) headers = { "API-Key": API_KEY, "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, params=params, ) return response.json() history = list_portfolio_history("ABCD1234EFGH5678") for point in history["data"]: print(point["timestamp"], point.get("value")) ``` ```javascript Javascript theme={null} async function listPortfolioHistory(accountId) { const endpoint = `/v1/accounts/${accountId}/portfolio/history`; const params = new URLSearchParams(); params.set('start_timestamp', '2026-04-01T00:00:00Z'); params.set('end_timestamp', '2026-04-30T23:59:59Z'); params.set('quote_symbol', 'USD'); params.set('quote_type', 'fiat'); params.set('assets[0][symbol]', 'BTC'); params.set('assets[0][type]', 'crypto'); params.set('assets[1][symbol]', 'ETH'); params.set('assets[1][type]', 'crypto'); const signature = getPaywardSignature(endpoint, null, API_SECRET, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Sign': signature, }, }); return response.json(); } const history = await listPortfolioHistory('ABCD1234EFGH5678'); for (const point of history.data) { console.log(point.timestamp, point.value); } ``` Use the history endpoint for: * Portfolio value charts with `timestamp` and `value`. * Historical PnL views with `total_pnl`, when available. * Historical asset-balance charts when you request `assets` filters with both `symbol` and `type`. * Quote-aware charts with top-level `quote_symbol`, `quote_type`, `start_timestamp`, and `end_timestamp`. The history endpoint returns a maximum of 365 data points per request. Use a narrower time window when you need fewer points. A missing asset balance at the start of the range means the user did not hold that asset at that time. A zero balance means the user may have held the asset previously, but did not hold it at that snapshot. The latest daily balance can be delayed by up to about five hours after UTC midnight while data is processed. See the [List Portfolio History API reference](/api-reference/portfolio/list-portfolio-history) for all query parameters and response fields. ## API reference | Endpoint | Method | Description | | -------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- | | `/v1/accounts/{account_id}/portfolio` | GET | Get account-level values and per-asset balances in one snapshot | | `/v1/accounts/{account_id}/portfolio/transactions` | GET | List portfolio transactions for deposits, withdrawals, transfers, swaps, and earn rewards | | `/v1/accounts/{account_id}/portfolio/history` | GET | List historical balances and valuations for a portfolio | | `/v1/webhooks` | POST | Subscribe to Portfolio transaction change notifications | # Earn Source: https://docs.services.payward.com/tabs/developer-documentation/earn/earn The Earn API exposes Auto-Earn: enable or disable yield generation across eligible assets, read the user's allocations and historical rewards, and discover which assets are auto-earn eligible by user or by country. ## 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. The supported yield sources are `staking` and `base_rewards`; more may be added without breaking the response shape. The integration shape is six endpoints: | Goal | Endpoint | Method | | ---------------------------------------------- | ------------------------------------------------- | ------ | | Discover Auto-Earn eligibility by country | `/v1/earn/auto/assets` | GET | | Discover Auto-Earn eligibility for an account | `/v1/accounts/{account_id}/earn/auto/assets` | GET | | Set Auto-Earn preferences (per yield source) | `/v1/accounts/{account_id}/earn/auto` | PATCH | | Read current preferences (incl. pending state) | `/v1/accounts/{account_id}/earn/auto` | GET | | List the user's current per-asset allocations | `/v1/accounts/{account_id}/earn/auto/allocations` | GET | | List historical rewards & next payout estimate | `/v1/accounts/{account_id}/earn/auto/rewards` | GET | If you have used Kraken's [Spot REST Earn](https://docs.kraken.com/api/docs/guides/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](/tabs/developer-documentation/get-started/authentication)). * 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 Show what's available in a country before you have an account. User-agnostic. `GET /v1/earn/auto/assets?country=US` Show APY and per-user allocation cap for the account's eligible assets. `GET /v1/accounts/{account_id}/earn/auto/assets` Enable or disable a yield source. Async. `PATCH /v1/accounts/{account_id}/earn/auto` Read current preferences and watch `pending_enabled` / `pending_disabled` resolve. `GET /v1/accounts/{account_id}/earn/auto` 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 `PATCH` is a no-op. There is no separate eligibility endpoint — presence in this response is the eligibility check. | State | Meaning | What to show | | ------------------ | ------------------------------------------------------ | ----------------------------------------------------- | | `enabled` | Yield source is active. Eligible balances are earning. | "On". | | `disabled` | Yield source is off. No new allocations. | "Off". | | `pending_enabled` | `PATCH` accepted; provisioning in progress. | "Enabling…" with a spinner. Don't allow re-toggling. | | `pending_disabled` | `PATCH` accepted; unallocation in progress. | "Disabling…" with a spinner. Don't allow re-toggling. | `PATCH /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 account at a time. Submitting a `PATCH` 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: | Field | Description | | -------- | ------------------------------------------------------ | | `symbol` | Ticker symbol (e.g. `ETH`, `EUR`). | | `type` | `fiat`, `crypto`, `stablecoin`, `xstock`, or `equity`. | | `name` | Human-readable name (e.g. `"Ethereum"`). | All **monetary fields inside an entry** are plain decimal strings: | Suffix | Denominated in | | ----------- | -------------------------------------------------------------------------------------------------------- | | (none) | The entry's own asset. `allocated: "100.1234"` means 100.1234 ETH on an `ETH` entry. | | `_in_quote` | The 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](/tabs/developer-documentation/get-started/authentication) for the full algorithm. ```python Python theme={null} 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 ``` ```javascript Javascript theme={null} import crypto from 'crypto'; const API_KEY = process.env.PWS_API_KEY; const API_SECRET = process.env.PWS_API_SECRET; const BASE_URL = 'https://api.services.payward.com'; function getPaywardSignature(urlpath, data, secret, nonce, params = null) { const encoded = data === null ? String(nonce) : String(nonce) + JSON.stringify(data); let signPath = urlpath; if (params) { signPath += '?' + new URLSearchParams(params).toString(); } const sha256Hash = crypto.createHash('sha256').update(encoded).digest(); const message = Buffer.concat([Buffer.from(signPath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } function pwsHeaders(signature, nonce, { idempotent = false } = {}) { const headers = { 'API-Key': API_KEY, 'API-Sign': signature, 'API-Nonce': String(nonce), 'Content-Type': 'application/json', }; if (idempotent) { headers['Idempotency-Key'] = crypto.randomUUID(); } return headers; } ``` Unless an endpoint documents an exception, 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`. ```python Python theme={null} 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']}") ``` ```javascript Javascript theme={null} async function listAutoEarnAssetsByCountry(country, pageSize = 20, pageToken = null) { const endpoint = '/v1/earn/auto/assets'; const nonce = process.hrtime.bigint().toString(); const params = new URLSearchParams({ country, page_size: String(pageSize) }); if (pageToken) params.set('page_token', pageToken); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const catalog = await listAutoEarnAssetsByCountry('US'); for (const asset of catalog.data) { console.log(`${asset.symbol} (${asset.name}): ${asset.apy}% APY, cap ${asset.user_cap} ${asset.symbol}`); } ``` ### Response example ```json theme={null} { "data": [ { "symbol": "ETH", "type": "crypto", "name": "Ethereum", "yield_source": "staking", "apy": "3.41", "user_cap": "1000" } ], "next_page_token": null } ``` | Field | How to use it | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `yield_source` | Yield source that backs this asset (`staking` or `base_rewards`). | | `apy` | Estimated annual percentage yield as a decimal string (e.g. `"3.41"` for 3.41%). | | `user_cap` | Hard maximum a single user is allowed to auto-earn for this asset, in the entry's asset. Show as a "max" hint. Absent when there is no cap. | | `next_page_token` | Opaque 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. ```python Python theme={null} 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 = "WFFGB6Z8AK5WF9JL" 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']})") ``` ```javascript Javascript theme={null} async function listAccountAutoEarnAssets(accountId, pageSize = 20, pageToken = null) { const endpoint = `/v1/accounts/${accountId}/earn/auto/assets`; const nonce = process.hrtime.bigint().toString(); const params = new URLSearchParams({ page_size: String(pageSize) }); if (pageToken) params.set('page_token', pageToken); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const accountId = 'WFFGB6Z8AK5WF9JL'; const accountAssets = await listAccountAutoEarnAssets(accountId); for (const asset of accountAssets.data) { console.log(`${asset.symbol}: ${asset.apy}% APY (cap ${asset.user_cap} ${asset.symbol})`); } ``` ### Response example ```json theme={null} { "data": [ { "symbol": "ETH", "type": "crypto", "name": "Ethereum", "yield_source": "staking", "apy": "3.41", "user_cap": "1000" } ], "next_page_token": null } ``` ## Step 3: set Auto-Earn preferences Send a `PATCH` 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: `staking`, `base_rewards`. Allowed values: `"enabled"`, `"disabled"`. ```python Python theme={null} 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.patch( 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}") ``` ```javascript Javascript theme={null} async function setAutoEarnPreferences(accountId, preferences) { const endpoint = `/v1/accounts/${accountId}/earn/auto`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, preferences, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'PATCH', headers: pwsHeaders(signature, nonce, { idempotent: true }), body: JSON.stringify(preferences), }); return { status: response.status, body: await response.json() }; } const { status } = await setAutoEarnPreferences(accountId, { staking: 'enabled' }); console.log(`Toggle accepted: HTTP ${status}`); ``` ### Response example ```json theme={null} { "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 `PATCH` issued before the pending state resolves returns `409 Conflict` — see the [preference lifecycle](#auto-earn-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 `PATCH` becomes a no-op. ```python Python theme={null} 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") ``` ```javascript Javascript theme={null} async function getAutoEarnPreferences(accountId) { const endpoint = `/v1/accounts/${accountId}/earn/auto`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } async function waitForSettled(accountId, source, maxAttempts = 60) { const pending = new Set(['pending_enabled', 'pending_disabled']); for (let i = 0; i < maxAttempts; i++) { const prefs = await getAutoEarnPreferences(accountId); const state = prefs[source]; console.log(`${source}: ${state}`); if (state === undefined || !pending.has(state)) return prefs; await new Promise(resolve => setTimeout(resolve, 2000)); } throw new Error(`${source} did not settle in time`); } const prefs = await waitForSettled(accountId, 'staking'); ``` ### Response example ```json theme={null} { "data": { "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. ```python Python theme={null} 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})" ) ``` ```javascript Javascript theme={null} async function listAutoEarnAllocations(accountId, quoteSymbol = 'USD', pageToken = null, pageSize = 20) { const endpoint = `/v1/accounts/${accountId}/earn/auto/allocations`; const nonce = process.hrtime.bigint().toString(); const params = new URLSearchParams({ quote_symbol: quoteSymbol, quote_type: 'fiat', page_size: String(pageSize), }); if (pageToken) params.set('page_token', pageToken); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const allocations = await listAutoEarnAllocations(accountId, 'EUR'); const quote = allocations.quote_symbol; console.log(`Total allocated: ${allocations.total_allocated_in_quote} ${quote}`); for (const entry of allocations.data) { console.log(` ${entry.allocated} ${entry.symbol} (~${entry.allocated_in_quote} ${quote})`); } ``` ### Response example ```json theme={null} { "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 } ``` | Field | How to use it | | ----------------------------- | -------------------------------------------------------------------------------------- | | `quote_symbol` / `quote_type` | Echo of the requested quote asset. Use to format `*_in_quote` values. | | `total_allocated_in_quote` | Cross-asset allocation total in the quote currency. Use it for a dashboard hero value. | | `data[].allocated` | Per-asset amount currently earning, in the entry's asset. | | `data[].allocated_in_quote` | Same per-asset amount in the quote currency. | | `next_page_token` | Opaque 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. ```python Python theme={null} 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})" ) ``` ```javascript Javascript theme={null} async function listAutoEarnRewards(accountId, quoteSymbol = 'USD', pageToken = null, pageSize = 20) { const endpoint = `/v1/accounts/${accountId}/earn/auto/rewards`; const nonce = process.hrtime.bigint().toString(); const params = new URLSearchParams({ quote_symbol: quoteSymbol, quote_type: 'fiat', page_size: String(pageSize), }); if (pageToken) params.set('page_token', pageToken); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const rewards = await listAutoEarnRewards(accountId, 'EUR'); const quote = rewards.quote_symbol; console.log(`Total rewarded: ${rewards.total_rewarded_in_quote} ${quote}`); if (rewards.next_reward_date) { console.log(`Next payout: ${rewards.next_reward_date}`); } for (const entry of rewards.data) { let line = ` ${entry.symbol}: rewarded ${entry.rewarded} ${entry.symbol}`; if (entry.estimated_next_reward !== undefined) { line += `, next ~${entry.estimated_next_reward} ${entry.symbol}`; } console.log(line); for (const side of entry.other_assets ?? []) { console.log(` + ${side.rewarded} ${side.symbol} (~${side.rewarded_in_quote} ${quote})`); } } ``` ### Response example ```json theme={null} { "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 } ``` | Field | How to use it | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quote_symbol` / `quote_type` | Echo of the requested quote asset. Use to format `*_in_quote` values. | | `total_rewarded_in_quote` | All-time, cross-asset rewards in the quote currency. Headline figure for the rewards card. | | `next_reward_date` | Timestamp of the next payout across all assets. Absent when nothing is pending. | | `data[].rewarded` / `_in_quote` | Per-asset all-time rewards, in the entry's asset and in the quote currency. | | `data[].estimated_next_reward` / `_in_quote` | Best-effort estimate of the upcoming reward for that asset. Absent when no payout is pending. | | `data[].other_assets` | Rewards 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_token` | Opaque 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: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/accounts/WFFGB6Z8AK5WF9JL/portfolio/transactions?types=earn_reward&start_timestamp=2026-04-01T00:00:00Z&end_timestamp=2026-04-30T23:59:59Z"e_symbol=USD" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" ``` Use the [Portfolio guide](/tabs/developer-documentation/accounts/portfolio) for the activity feed and pagination, and the Reports endpoints for downloadable settlement statements. ## Error handling All error responses share a standard envelope: ```json theme={null} { "error": { "type": "earn_error", "status": 400, "code": "bad_request", "instance": "req-abc123", "causes": [ { "field": "country", "message": "must match pattern \"^[A-Z]{2}$\"" } ] } } ``` | Field | Description | | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | | `type` | Error class (e.g. `earn_error`). | | `status` | HTTP status code. | | `code` | Machine-readable error code in snake\_case (e.g. `bad_request`, `not_eligible`). | | `instance` | Request-Id for this occurrence — include it in support tickets. | | `doc_url` | Optional link to documentation for this error. | | `causes` | Optional array of per-field validation failures. Each entry has a `field` (dot-notation path) and a human-readable `message`. | | HTTP status | Cause | Remediation | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `400 Bad Request` | Invalid `country` (not ISO 3166-1 alpha-2), unsupported `quote_type`, bad enum value in the `PATCH` body, or bad pagination tokens | Validate query parameters and body before sending. Inspect `causes[]` for per-field details. | | `401 Unauthorized` | Missing or invalid signature, key, or nonce | Verify `API-Key`, `API-Sign`, and `API-Nonce`. The nonce must increase. | | `403 Forbidden` | Account is not authorized to use Earn | Confirm the account's product entitlements. | | `404 Not Found` | Unknown `account_id` | Verify the `account_id` belongs to your partner. | | `409 Conflict` | An Auto-Earn preference change is already in progress for this account — only one may be in flight at a time | Poll `GET /v1/accounts/{account_id}/earn/auto` until no yield source is in `pending_enabled` / `pending_disabled`, then retry. | | `429 Too Many Requests` | Rate limit hit | Honor `Retry-After` and back off exponentially. | ### Best practices 1. **Treat the `PATCH` 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 account.** Wait for the pending state to resolve before submitting another change; otherwise the second `PATCH` 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 | Endpoint | Method | Description | | ------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- | | `/v1/earn/auto/assets` | GET | List Auto-Earn assets eligible in a country (user-agnostic). Required `country` query parameter. | | `/v1/accounts/{account_id}/earn/auto/assets` | GET | List Auto-Earn assets available to the given account, with APY and per-user cap. | | `/v1/accounts/{account_id}/earn/auto` | PATCH | Set Auto-Earn preferences per yield source. Async; empty `data: {}` on success. | | `/v1/accounts/{account_id}/earn/auto` | GET | Get current Auto-Earn preferences. Includes `pending_enabled` / `pending_disabled` while a `PATCH` is being applied. | | `/v1/accounts/{account_id}/earn/auto/allocations` | GET | List the user's current per-asset allocations and the cross-asset total in the requested quote currency. | | `/v1/accounts/{account_id}/earn/auto/rewards` | GET | List historical rewards per asset, the all-time total in the quote currency, and the next payout estimate. | # Asset, funding rail, and geo coverage Source: https://docs.services.payward.com/tabs/developer-documentation/get-started/asset-funding-rail-and-geo-coverage This page provides an overview of the assets, funding methods, and geographic regions supported by Payward Services. Coverage varies by product (Trade vs. Payments) and is subject to regulatory requirements in each jurisdiction. Coverage is continuously expanding. For the most current list of supported assets and instruments, query the List Assets endpoint (`GET /v1/assets`) or the List Tradable Assets endpoint. Contact your integration engineer for corridor-specific availability. ## Supported assets Payward Services supports a broad range of digital assets for trading, custody, and payments. Assets are categorized by type: * **Major Cryptocurrencies**: Bitcoin (BTC), Ethereum (ETH), Solana (SOL), Cardano (ADA), Polkadot (DOT), Avalanche (AVAX), Polygon (MATIC), and other top-tier Layer 1 and Layer 2 assets. * **Stablecoins**: USDC, USDT, DAI, and other USD-pegged stablecoins. Stablecoins are the primary transfer medium for cross-border payment flows. Multiple network options are supported (e.g., USDC on Ethereum, Solana, Polygon, Arbitrum). * **Tokenized Assets (xStocks)**: Tokenized equities representing fractional shares of traditional stocks. See the xStocks documentation for the current list of available tokenized assets. * **DeFi and Utility Tokens**: A broad selection of ERC-20, SPL, and other network-native tokens. Availability varies by jurisdiction and is subject to regulatory review. ### Asset availability by product | Product | Asset Types | Notes | | ---------------------- | ----------------------------- | --------------------------------------------------------- | | Trade (onchain quotes) | xStocks + USDC | Wallet-funded quotes for xStock and USDC pairs | | Trade (CLOB) | Major pairs | Order book trading for high-liquidity pairs | | Custody | All supported assets | Segregated custody for all assets on platform | | Payments | Stablecoins + fiat currencies | Stablecoins are the transfer medium; fiat for on/off ramp | | Staking | Select PoS assets | ETH, SOL, DOT, ADA, and others | | xStocks | Tokenized equities | See xStocks integration guide | # Authentication Source: https://docs.services.payward.com/tabs/developer-documentation/get-started/authentication ## Authentication parameters The Payward Services API uses HMAC signing. Every authenticated request must include the following headers: * `API-Key` HTTP header parameter: your public API key. * `API-Nonce` HTTP header parameter: monotonically increasing integer nonce. * `API-Sign` HTTP header parameter: HMAC-SHA512 signature of the request. ## Setting the API-Key parameter The value for the `API-Key` HTTP header parameter is your public API key. Contact your Payward account representative to obtain API credentials. From your API key-pair, clearly identify which key is public and which key is private. * The public key is sent in the `API-Key` header parameter. * The private key is **never** sent, it is only used to encode the signature for `API-Sign` header parameter. ## Setting the API-Nonce parameter The value for the `API-Nonce` HTTP header parameter must be a monotonically increasing integer for the API key. Use a high-resolution timestamp, such as nanoseconds since the Unix epoch, unless your integration already has a stronger monotonic counter. ## Setting the API-Sign parameter The value for the `API-Sign` HTTP header parameter is an HMAC-SHA512 signature of the request, base64-encoded. ```text theme={null} base64(HMAC-SHA512(secret, path_with_query + SHA256(nonce + body))) ``` ### Algorithm steps 1. **Choose a nonce:** generate a monotonically increasing integer. 2. **Build the nonce payload:** concatenate the nonce and the exact request body bytes. * For requests without a body: use the nonce only. * For requests with a body: use the exact bytes you send on the wire. 3. **Hash the nonce payload:** generate a SHA-256 digest. 4. **Build the message:** concatenate the URL path, including query string, and the SHA-256 digest. 5. **Sign:** generate HMAC-SHA512 over the message using your base64-decoded secret. 6. **Encode:** base64-encode the signature. ### Examples The following code snippets demonstrate how to generate the signature in Python and JavaScript. ```python Python theme={null} import hashlib import hmac import base64 import time import urllib.parse def get_payward_signature(urlpath, body, secret, nonce, params=None): """ Generate Payward Services API signature. Args: urlpath: API endpoint including query string (e.g., '/v1/assets') body: Request body string, or None for requests without a body secret: Base64-encoded API secret nonce: Monotonically increasing integer nonce params: Optional query parameters Returns: Base64-encoded signature string """ nonce_payload = str(nonce).encode("utf-8") if body is None else (str(nonce) + body).encode("utf-8") sign_path = urlpath if params: sign_path += "?" + urllib.parse.urlencode(params) message = sign_path.encode("utf-8") + hashlib.sha256(nonce_payload).digest() mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512) return base64.b64encode(mac.digest()).decode() api_secret = "your-api-secret-here" endpoint = "/v1/assets" nonce = time.time_ns() signature = get_payward_signature(endpoint, None, api_secret, nonce) print(f"API-Nonce: {nonce}") print(f"API-Sign: {signature}") ``` ```javascript Javascript theme={null} import crypto from 'crypto'; /** * Generate Payward Services API signature. * @param {string} urlpath - API endpoint including query string (e.g., '/v1/assets') * @param {string|null} body - Request body string, or null for requests without a body * @param {string} secret - Base64-encoded API secret * @param {string|number|bigint} nonce - Monotonically increasing integer nonce * @param {object|null} params - Optional query parameters * @returns {string} Base64-encoded signature */ function getPaywardSignature(urlpath, body, secret, nonce, params = null) { const noncePayload = body === null ? String(nonce) : String(nonce) + body; let signPath = urlpath; if (params) { signPath += '?' + new URLSearchParams(params).toString(); } const sha256Hash = crypto.createHash('sha256').update(noncePayload).digest(); const message = Buffer.concat([Buffer.from(signPath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } const apiSecret = 'your-api-secret-here'; const endpoint = '/v1/assets'; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, apiSecret, nonce); console.log(`API-Nonce: ${nonce}`); console.log(`API-Sign: ${signature}`); ``` ## Complete request example Here's a complete example making an authenticated GET request to list assets. ```python Python theme={null} import os import time import hashlib import hmac import base64 import requests API_KEY = os.environ.get("PAYWARD_API_KEY") API_SECRET = os.environ.get("PAYWARD_API_SECRET") BASE_URL = "https://api.services.payward.com" def get_payward_signature(urlpath, body, secret, nonce): nonce_payload = str(nonce).encode("utf-8") if body is None else (str(nonce) + body).encode("utf-8") message = urlpath.encode("utf-8") + hashlib.sha256(nonce_payload).digest() mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512) return base64.b64encode(mac.digest()).decode() def list_assets(): endpoint = "/v1/assets" nonce = time.time_ns() signature = get_payward_signature(endpoint, None, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, } response = requests.get(BASE_URL + endpoint, headers=headers) return response.json() assets = list_assets() print(assets) ``` ```javascript Javascript theme={null} import crypto from 'crypto'; const API_KEY = process.env.PAYWARD_API_KEY; const API_SECRET = process.env.PAYWARD_API_SECRET; const BASE_URL = 'https://api.services.payward.com'; function getPaywardSignature(urlpath, body, secret, nonce) { const noncePayload = body === null ? String(nonce) : String(nonce) + body; const sha256Hash = crypto.createHash('sha256').update(noncePayload).digest(); const message = Buffer.concat([Buffer.from(urlpath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } async function listAssets() { const endpoint = '/v1/assets'; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, }, }); return response.json(); } const assets = await listAssets(); console.log(assets); ``` ## Query parameters in signature When your request includes query parameters, they must be included in the URL path used for signature generation. Use the exact same path string in both the signature and the request URL. ```javascript theme={null} const params = { page_size: 10, quote: 'USD' }; const queryString = new URLSearchParams(params).toString(); const signaturePath = `/v1/assets?${queryString}`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(signaturePath, null, API_SECRET, nonce); ``` ## Request body in signature For requests that send a body (POST, PUT), the body bytes used to compute the signature must match the bytes sent on the wire exactly. Differences in whitespace, key ordering, or encoding will produce a signature mismatch. The recommended pattern is to serialize the body once and reuse the same string for both signing and the HTTP request: ```javascript theme={null} const body = JSON.stringify({ from_asset: 'USD', to_asset: 'BTC', amount: '100.00' }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature('/v1/swap/quote', body, API_SECRET, nonce); await fetch(`${BASE_URL}/v1/swap/quote`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', }, body, }); ``` ## Troubleshooting | Error | Cause | Solution | | ------------------- | ----------------------- | --------------------------------------------------- | | `Invalid signature` | Signature doesn't match | Verify secret encoding, nonce, path, and body bytes | | `Missing API-Key` | Header not set | Check the header name is exactly `API-Key` | | `Invalid nonce` | Nonce was reused | Generate a larger nonce for every request | # Use cases Source: https://docs.services.payward.com/tabs/developer-documentation/get-started/use-cases Payward Services supports two primary categories of use cases: Trade and Payments Orchestration. Each category serves different partner types and end-user needs, though many partners combine both. Both categories can use Payward-managed verification when end users need KYC. Use [Hosted KYC](/tabs/developer-documentation/verifications/hosted-kyc) when Payward should collect evidence in a hosted UI. ## Trade Trade use cases involve enabling end users to buy, sell, and hold digital assets. This includes spot crypto trading, stablecoin conversion, tokenized asset trading, and earn/staking products. The trade use case is most commonly adopted by banks, brokerages, and fintechs that want to offer crypto trading within their existing product experience. Typical implementations include: * **In-app crypto trading:** End users buy and sell crypto assets directly within the partner's mobile or web application. PWS provides onchain quote execution, CLOB execution, custody, and settlement. The partner controls the UI and branding. * **Portfolio management:** Crypto holdings appear alongside traditional assets in the user's portfolio view. PWS provides portfolio APIs for balance, history, and performance data. * **Recurring purchases:** Users set up automated, scheduled crypto purchases (e.g., \$100 of BTC every Monday). PWS handles scheduling and execution via the Price Trigger Swaps API. * **Earn and staking:** Users earn yield on idle crypto holdings. PWS provides staking infrastructure and auto-earn toggles. * **Tokenized equities:** Users trade tokenized versions of traditional stocks (xStocks) alongside crypto. PWS provides atomic onchain quote execution for xStock trades. ## Payments orchestration Payments use cases involve moving value across borders using crypto and stablecoin rails. This includes remittance, vendor payouts, merchant settlement, and treasury operations. The payments use case is designed for companies that move money at scale and want faster, cheaper alternatives to traditional correspondent banking. Typical implementations include: * **Cross-border remittance:** Enable P2P money transfers between individuals in different countries using stablecoin rails. Fiat on-ramp in the sender's country, stablecoin transfer, fiat off-ramp in the recipient's country. * **Vendor/supplier payouts:** Pay international vendors and suppliers in their local currency via stablecoin-to-fiat conversion. Attach invoice references for reconciliation. * **Merchant settlement:** Settle e-commerce transactions in stablecoins, then convert to the merchant's preferred fiat currency for bank withdrawal. * **Contractor/gig worker payroll:** Pay international contractors in stablecoins or their local fiat currency. Faster and cheaper than SWIFT wires. * **Treasury operations:** Corporate treasury teams use PWS to hold, convert, and transfer stablecoin balances across subsidiaries and geographies. # Assets Source: https://docs.services.payward.com/tabs/developer-documentation/market-data/assets Use the Assets API to discover supported assets, check payment availability, and show market context before users deposit, withdraw, or trade. ## Overview The Assets API is the catalog for assets available through Payward Services. Use it before a payment or trading flow to decide which assets to show, how much precision to accept, and whether deposits, withdrawals, or swaps are currently enabled. The same responses also include display metadata and market data. Use this data to build asset pickers, payment setup screens, and price history views. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)) * Base URL: `https://api.services.payward.com` ## Asset discovery workflow Use this flow when you build a screen that lets a user choose an asset, review its details, and continue into a deposit, withdrawal, or trade. Filter the catalog to assets enabled for the action you want to offer. `GET /v1/assets` Fetch the asset again when the user selects it. Check current status, precision, logo, and quote-denominated market data. `GET /v1/assets/{asset_type}/{asset_symbol}` Fetch historical rates when you need a chart, recent trend, or change-over-time display. `GET /v1/assets/{asset_type}/{asset_symbol}/rates` Use the selected `symbol` with the deposits, withdrawals, or trade endpoints. See [Deposits and withdrawals](/tabs/developer-documentation/payments/deposits-and-withdrawals). Asset prices and rates are useful for display and discovery. Do not use them as executable trade quotes. Use the [Swap API](/tabs/developer-documentation/trade/swap) when you need a locked trading price. ## Step 1: list available assets Call `GET /v1/assets` to build the asset list for your UI. Filter by asset type and capability so users only see assets that support the action they are trying to complete. ```python Python theme={null} def list_assets(page_token=None): endpoint = "/v1/assets" params = [ # Repeat `type` to filter on several asset types at once. ("type", "crypto"), ("type", "stablecoin"), ("deposit", "true"), ("withdraw", "true"), ("quote_symbol", "USD"), ("quote_type", "fiat"), ("sort", "market_cap_rank"), ("sort_direction", "asc"), ] if page_token: params.append(("page_token", page_token)) response = requests.get( f"{BASE_URL}{endpoint}", params=params, ) return response.json() assets = list_assets() for asset in assets["data"]: print(asset["symbol"], asset["status"]["deposit"]["enabled"]) ``` ```javascript Javascript theme={null} async function listAssets(pageToken = null) { const endpoint = '/v1/assets'; const params = new URLSearchParams(); // Repeat `type` to filter on several asset types at once. params.append('type', 'crypto'); params.append('type', 'stablecoin'); params.set('deposit', 'true'); params.set('withdraw', 'true'); params.set('quote_symbol', 'USD'); params.set('quote_type', 'fiat'); params.set('sort', 'market_cap_rank'); params.set('sort_direction', 'asc'); if (pageToken) params.set('page_token', pageToken); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', }); return response.json(); } const assets = await listAssets(); for (const asset of assets.data) { console.log(asset.symbol, asset.status.deposit.enabled); } ``` Use these fields from each asset: | Field | How to use it | | ----------------------------------------------------------- | ------------------------------------------------- | | `symbol` and `type` | Identify the asset in later API calls. | | `name` and `logo` | Display the asset in your picker. | | `status.deposit.enabled` | Show or hide deposit entry points. | | `status.withdraw.enabled` | Show or hide withdrawal entry points. | | `status.swap_trading.enabled` | Show or hide trade entry points. | | `status.swap_trading.disabled_against` | Prevent unsupported swap pairs. | | `decimals.funding` | Validate deposit and withdrawal amounts. | | `decimals.swap` | Validate swap amounts. | | `price`, `market_cap`, `volume`, and `price_change_percent` | Show market context in the requested quote asset. | The list endpoint is cursor-paginated. If the response includes `next_page_token`, pass it as `page_token` to fetch the next page. See the [List Assets API reference](/api-reference/assets/list-assets) for all filters, sort options, and response fields. ## Step 2: inspect the selected asset Call `GET /v1/assets/{asset_type}/{asset_symbol}` when the user selects an asset. This gives you the same normalized shape as the list response, scoped to one asset. ```python Python theme={null} def get_asset(asset_type, asset_symbol, quote_symbol="USD", quote_type="fiat"): endpoint = f"/v1/assets/{asset_type}/{asset_symbol}" params = { "quote_symbol": quote_symbol, "quote_type": quote_type, } response = requests.get( f"{BASE_URL}{endpoint}", params=params, ) return response.json() btc = get_asset("crypto", "BTC") print(btc["data"]["status"]["withdraw"]["enabled"]) ``` ```javascript Javascript theme={null} async function getAsset(assetType, assetSymbol, quoteSymbol = 'USD', quoteType = 'fiat') { const endpoint = `/v1/assets/${assetType}/${assetSymbol}`; const params = new URLSearchParams({ quote_symbol: quoteSymbol, quote_type: quoteType, }); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', }); return response.json(); } const btc = await getAsset('crypto', 'BTC'); console.log(btc.data.status.withdraw.enabled); ``` Before you continue: * Check `status.deposit.enabled` before you create or show deposit addresses. * Check `status.withdraw.enabled` before you list withdrawal methods or let a user save a withdrawal address. * Use `decimals.funding` to format and validate payment amounts. * Use `quote_symbol` and `quote_type` consistently so your UI does not mix valuations from different quote assets. See the [Get Asset API reference](/api-reference/assets/get-asset) for the full response schema. ## Step 3: show price history Call `GET /v1/assets/{asset_type}/{asset_symbol}/rates` when you need historical prices for a chart or trend display. Set the quote asset, time window, and interval explicitly. ```python Python theme={null} def list_asset_rates(asset_type, asset_symbol, page_token=None): endpoint = f"/v1/assets/{asset_type}/{asset_symbol}/rates" params = { "quote_symbol": "USD", "quote_type": "fiat", "start_timestamp": "2026-04-21T00:00:00Z", "end_timestamp": "2026-04-26T23:59:59Z", "interval": "P1D", } if page_token: params["page_token"] = page_token response = requests.get( f"{BASE_URL}{endpoint}", params=params, ) return response.json() rates = list_asset_rates("crypto", "BTC") for rate in rates["data"]: print(rate["timestamp"], rate["price"]) ``` ```javascript Javascript theme={null} async function listAssetRates(assetType, assetSymbol, pageToken = null) { const endpoint = `/v1/assets/${assetType}/${assetSymbol}/rates`; const params = new URLSearchParams({ quote_symbol: 'USD', quote_type: 'fiat', start_timestamp: '2026-04-21T00:00:00Z', end_timestamp: '2026-04-26T23:59:59Z', interval: 'P1D', }); if (pageToken) params.set('page_token', pageToken); const response = await fetch(`${BASE_URL}${endpoint}?${params}`, { method: 'GET', }); return response.json(); } const rates = await listAssetRates('crypto', 'BTC'); for (const rate of rates.data) { console.log(rate.timestamp, rate.price); } ``` Supported intervals include `PT1M`, `PT5M`, `PT15M`, `PT30M`, `PT60M`, `PT4H`, `P1D`, `P7D`, and `P15D`. Use the `next_page_token` from the response when you need more history. See the [List Asset Rates API reference](/api-reference/assets/list-asset-rates) for all query parameters and pagination behavior. ## User-scoped asset views Each endpoint above has a user-scoped variant under `/v1/users/{user_id}/assets`. These return the same asset shape, scoped to one user — reflecting that user's eligibility and any per-account restrictions on deposit, withdrawal, or trading. Use the public endpoints to build a general catalog. Use the user-scoped variants when you already know which user the screen is for and want availability that matches that specific account. Pass the user's IIBAN as `user_id`: * `GET /v1/users/{user_id}/assets` * `GET /v1/users/{user_id}/assets/{asset_type}/{asset_symbol}` * `GET /v1/users/{user_id}/assets/{asset_type}/{asset_symbol}/rates` Query parameters, pagination, and response fields match their public counterparts. ## API reference | Endpoint | Method | Description | | -------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- | | `/v1/assets` | GET | List assets with filters for type, payment availability, and market data | | `/v1/assets/{asset_type}/{asset_symbol}` | GET | Get metadata, payment availability, precision, and market data for one asset | | `/v1/assets/{asset_type}/{asset_symbol}/rates` | GET | List historical rates for an asset in the requested quote asset | | `/v1/users/{user_id}/assets` | GET | List assets scoped to a specific user, with that user's availability and restrictions | | `/v1/users/{user_id}/assets/{asset_type}/{asset_symbol}` | GET | Get one asset scoped to a specific user | | `/v1/users/{user_id}/assets/{asset_type}/{asset_symbol}/rates` | GET | List historical rates for an asset scoped to a specific user | # Bank links Source: https://docs.services.payward.com/tabs/developer-documentation/payments/bank-links Link your users' bank accounts once through a hosted widget, then reference them for payouts and conversions with opaque, revocable identifiers — no raw bank details ever cross the API. The Bank Links API connects a user's bank accounts to their PWS account through a hosted linking widget. Your user authorizes their bank once; you get back stable, opaque identifiers for the connection and each shared account. Money movement — payouts today, with conversions referencing the same identifiers — uses those identifiers instead of account or routing numbers. **Not what you're looking for?** Bank links connect a user's own bank accounts for automated money movement. For fiat-to-crypto purchases via a hosted checkout UI, see [Ramp](/tabs/developer-documentation/payments/ramp). For rule-driven fiat ↔ crypto conversion on deposits, see [Conversion rules](/tabs/developer-documentation/payments/conversion-rules). ## The model A **bank link** (`bank_link_id`, prefixed `bal_`) is one connection to one institution — created when your user authorizes their bank through the widget. It owns one or more **account links** (`account_link_id`, prefixed `al_`), one per bank account the user chose to share. Money always moves against an account link. Anything that acts on the connection — reconnecting it, unlinking it, or it expiring or being revoked — acts on the bank link and takes all of its accounts with it. A second institution is a second, fully independent bank link. **What is never returned:** provider identifiers, access tokens, account owner names, and raw account or routing numbers. ## How it works A typical Bank Links integration follows three steps: Create a bank link for a linkable funding method. You get back a hosted-widget URL. Send your user to that URL. They authorize their bank and choose which accounts to share. Read the link to see which accounts you can move money against, and their identifiers. ## Base URL ``` https://api.services.payward.com ``` ## Authentication All requests require three headers: | Header | Description | | ----------- | --------------------------------------------------------------- | | `API-Key` | Your public API key | | `API-Nonce` | Monotonically increasing nonce, included in the signature | | `API-Sign` | HMAC signature over the request, computed with your private key | See [Authentication](/tabs/developer-documentation/get-started/authentication) for how to generate the signature. ## Step 1 — Create a bank link Create a bank link for a linkable funding method with [Create bank link](/api-reference/bank-links/create-bank-link). The response is `pending` with a hosted-widget URL: ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/funds/bank-links" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b", "return_url": "https://partner.example.com/return", "external_reference": "user-4711-primary" }' ``` ```json theme={null} { "data": { "bank_link_id": "bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d", "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b", "status": "pending", "accounts_eligibility_status": "unknown", "next_action": { "type": "hosted_link", "widget_url": "https://bank-link.services.payward.com/link/bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d", "expires_at": "2026-07-27T12:30:00Z" } } } ``` | Field | Required | Description | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `method_id` | Yes | The funding method to link — an id, never a name. Discover them with `GET /v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}` | | `return_url` | No | Where the widget sends your user when it finishes. Absolute `https`, no userinfo; anything else is rejected | | `external_reference` | No | Your own correlation value | A method that does not support linking is rejected with `bank_link_method_not_linkable`. A `pending` link reports `accounts_eligibility_status: "unknown"` — it has no accounts to evaluate yet, so that is not a denial. ## Step 2 — Send your user to the widget Send them to `widget_url`. They pick their institution, authenticate with their bank, and choose which accounts to share. When they finish, the link becomes `linked`. The widget URL works once. If you supplied a `return_url`, the widget returns your user to it when it is done — read the link to find out what was shared. If the session expires before they open it, or they abandon it partway, create a new bank link. ## Step 3 — Read the link [Get bank link](/api-reference/bank-links/get-bank-link) returns one link. [List bank links](/api-reference/bank-links/list-bank-links) returns an account's links, newest first. List is cursor-paginated. Use `page_size` to set the first page size (default 20, maximum 25). If a response includes `next_page_token`, pass that value as `page_token` to fetch the next page; the token carries its own page size, so a new `page_size` sent alongside it is ignored. An account with no links returns an empty body. A link appears in the list once it has finished linking. Links still `pending` and links you have unlinked are not listed; `expired` ones are. ```json theme={null} { "data": { "bank_link_id": "bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d", "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b", "status": "linked", "institution": { "name": "U.S. Bank" }, "account_links": [ { "account_link_id": "al_7cec1ea9-f52e-4766-b02b-6113eaaa4d4e", "account_last4": "0000", "status": "active", "eligibility_status": "eligible", "eligible_rails": ["ach"] } ], "accounts_eligibility_status": "eligible", "linked_at": "2026-07-27T12:04:11Z" } } ``` `institution` is present once the link has completed and the provider named the bank; it is omitted on a `pending` link and whenever no name is available. Only `bank_link_id`, `method_id`, `status` and `accounts_eligibility_status` are always there. `linked_at` is when the connection became active, so it appears once the link has finished linking and not before. `expires_at` is when the connection is due to expire, and appears only when the bank has told us a date — plenty of connections never carry one, so treat its absence as "no expiry reported" rather than "no expiry". `account_links` lists the accounts currently shared with you, each with a `status` of `active`. An account your user stops sharing is dropped from the list on the next read rather than turning up as `removed`, so compare the ids you hold against what a read returns — an id you no longer see is gone. There are no webhooks for any of this; reading the link is how you find out. `not_linked_accounts` lists accounts your user picked at their bank that could not be linked. They have no `account_link_id`, are excluded from `account_links` and from `accounts_eligibility_status`, and cannot move money. They are reported so you can tell your user an account they chose was left out; if a later reconnect links one, it becomes an ordinary account link. Each entry carries an `account_last4` and a `reason`: `missing_identity` when the provider could not verify the account holder, `missing_balance` when it could not read the balance, or `unknown`. Treat any reason you do not recognize as `unknown` — new ones may be added. The field is absent or empty when every selected account linked. Four fields carry the eligibility story. Eligibility is computed fresh on every read — nothing is cached, so the answer can change between reads as ownership or rail capability changes. * `eligibility_status` — this account's own verdict, always present. `eligible`: its owner is validated and at least one rail is known. `ineligible`: a check ruled it out, or the account is no longer linked. `unknown`: a check could not answer or has not run. * `eligible_rails` — the transfer rails this account can use right now (for example `ach`, `rtp`). Omitted when there are none, so read it as absent-or-listed rather than expecting an empty array. * `ineligibility_reasons` — why no rail is eligible, for example `name_mismatch` when the account holder does not match the PWS account's verified identity. Omitted when there are none. An account that could not be evaluated carries no reasons, so absence is not proof of eligibility. * `accounts_eligibility_status` — the link-level fold of the accounts' own statuses, always present. `eligible`: at least one account has a validated owner and a usable rail. `ineligible`: every account is conclusively ruled out (a link that finished linking with no accounts is `ineligible` too). `unknown`: not established on this read — at least one account could not be fully evaluated. Treat `unknown` as "not verified yet", never as ineligible. ## Reconnecting When a link reports `expired` — the connection lapsed, or your user or their bank revoked it — send your user back through the widget with [Reconnect bank link](/api-reference/bank-links/reconnect-bank-link). A link you have unlinked cannot be reconnected — link the account again instead. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/funds/bank-links/bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d/reconnect" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "return_url": "https://partner.example.com/return" }' ``` The link comes back in `pending` with a `widget_url`. Send your user there as you did on create; they re-authorize with their bank and the widget resumes the existing connection. That URL is the same address create returned — reconnect puts a new session behind it, so an address you have seen before works again. Calling reconnect twice before the widget is opened returns that same session with its original `expires_at`, so a repeat call does not buy a full session lifetime. The bank link itself does not change: `bank_link_id` and `method_id` stay the same. Its accounts re-synchronize against whatever your user shares this time — an account they share again keeps its existing `account_link_id`, even one that had been dropped earlier, a newly shared account appears as a new account link, and one they leave out disappears. ## Unlinking [Unlink bank link](/api-reference/bank-links/unlink-bank-link) severs the whole connection: * the link becomes `unlinked` and every account it had shared becomes `removed`. This response is the only place you see that status; * the response is the final snapshot, and repeating the call returns the same one — unlinking is idempotent; * the link then disappears from both reads: Get answers `404` with `bank_link_not_found`, and List omits it; * money movement against its accounts stops working; * unlinking a link that never finished linking cancels it the same way. Unlinking deletes the connection at the provider, not just our record of it, so there is nothing left to reconnect to. Linking the same bank account again means creating a new bank link, and it comes back with a new `bank_link_id` and a new `account_link_id` for every account — the same bank account does not get its old ids back. The provider can refuse to sever the connection — for example while transactions are still settling. That comes back as `409` with `bank_link_provider_rejected`, and the link keeps the status it had; retry once the condition clears. Do not treat a `DELETE` as unconditionally successful. A link busy with an in-flight provider operation — an activation, a reconnect, or an earlier unlink attempt — returns `503`; retry shortly. ## Errors Bank-link operations return the standard PWS error envelope with stable `error.code` values. These four are specific to bank links: | Code | Where | Meaning | What to do | | --------------------------------------- | ----------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ | | `bank_link_method_not_linkable` | 400 on create | The funding method does not support linking for this user | Check the method via funds discovery | | `bank_link_not_found` | 404 on get, reconnect, unlink | Unknown link, already unlinked, or another account's | Treat as gone; link again if needed | | `bank_link_provider_rejected` | 409 on unlink | The provider refused to sever the connection right now | Retry once the condition clears | | `bank_link_activation_restart_required` | 409 on reconnect | An activation failed and left the outcome unknown | Create a new bank link and send your user through the widget again | Whatever your user runs into inside the widget — an authorization that expires mid-flow, an institution already linked, a connection limit, a provider outage — the widget handles, and none of it reaches your API calls. The link's own state is the outcome. ## API reference | Endpoint | Description | | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | [`POST /v1/accounts/{account_id}/funds/bank-links`](/api-reference/bank-links/create-bank-link) | Create a bank link and get its hosted-widget URL | | [`GET /v1/accounts/{account_id}/funds/bank-links`](/api-reference/bank-links/list-bank-links) | List an account's bank links, newest first | | [`GET /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}`](/api-reference/bank-links/get-bank-link) | Get one bank link with its account links and eligibility | | [`POST /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}/reconnect`](/api-reference/bank-links/reconnect-bank-link) | Send the user back through the widget for an `expired` link | | [`DELETE /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}`](/api-reference/bank-links/unlink-bank-link) | Sever the connection and mark every account link `removed` | # Conversions Source: https://docs.services.payward.com/tabs/developer-documentation/payments/conversion-rules The conversions API supports reusable conversion rules and one-off on-demand conversions for fiat ↔ crypto flows. The conversions API supports two patterns: reusable conversion rules that react to matching deposits, and one-off on-demand conversions. It is designed for partners building wallet-native or payments products who want to offer their users a seamless on- and off-ramp experience — without relying on card widgets or assembling multiple providers for KYC, rails, conversion, and settlement. The core primitive is a **conversion rule**: a persistent per-account configuration that provisions a dedicated inbound endpoint (a named virtual bank account for fiat deposits, or a deposit address for crypto deposits) and automatically converts and settles any matching deposit to the destination you have defined. Each time a matching deposit lands, the rule fires and produces a **conversion transaction**. **Not what you're looking for?** Conversion rules handle asset exchange with automatic fund movement across rails. If you need to move funds between accounts in the same asset without conversion, see [Transfers](/tabs/developer-documentation/payments/transfers). To execute an institutional FX-style trade against a locked quote, see [Swap](/tabs/developer-documentation/trade/swap). For fiat-to-crypto purchases via a hosted checkout UI, see [Ramp](/tabs/developer-documentation/payments/ramp). ## How it works A conversion rule is a persistent `from` → `to` configuration scoped to the selected account identified by the path's `{account_id}`. When you create a rule, PWS provisions a dedicated inbound endpoint — a virtual IBAN for fiat, or a deposit address for crypto — and returns it on the rule's `from` side. You share those payment instructions with your end customer. Every matching credit to that endpoint automatically triggers a conversion and settles the converted asset to the rule's `to` destination. Rules remain active and reusable as long as the account's KYC profile is valid. For a single on-demand conversion, use the on-demand conversions API instead of creating a rule. ## On-demand conversions An on-demand conversion executes once for the selected account using the source and destination in the request. It does not create a reusable conversion rule. The conversion is processed asynchronously, so the create response returns its initial status. Use the get or list endpoint to monitor it until it completes or fails. `{account_id}` is the identifier of the selected account that owns the conversion. It is not the user identifier. ### Create an on-demand conversion Provide a balance source and either a wallet or bank destination. The example below uses a wallet destination. Set exactly one amount: * `from.amount` fixes the amount debited from the account balance. The API calculates the amount delivered to the destination. * `to.amount` fixes the amount delivered to the destination. The API calculates the amount debited from the account balance. Amounts are decimal strings. Requests that provide both amounts or neither amount are rejected. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/WVSD33HRMGSZUBM7/on-demand-conversions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{ "from": { "symbol": "USD", "type": "balance", "amount": "1000.00" }, "to": { "symbol": "ETH", "type": "wallet", "wallet": { "via": "ethereum", "address": "0xabcdef1234567890abcdef1234567890abcdef12", "memo": "destination-memo", "tag": "destination-tag" } } }' ``` #### Balance-to-bank destinations A balance source can also deliver a fiat asset to a bank account. Each bank rail supports a specific payout currency and requires specific bank details: | Rail | Payout currency | Required `bank_account` fields | | ------ | --------------- | ------------------------------ | | `ach` | `USD` | `account_link_id` | | `rtp` | `USD` | `account_link_id` | | `sepa` | `EUR` | `iban`; `bic` is optional | | `fps` | `GBP` | `sort_code`, `account_number` | ACH and RTP destinations use an `account_link_id` from [Bank links](/tabs/developer-documentation/payments/bank-links). SEPA and FPS destinations use their rail-specific routing fields. Do not combine linked-account and raw routing fields. For example, this request fixes the amount debited from the balance and calculates the EUR amount delivered by SEPA: ```json theme={null} { "from": { "symbol": "USD", "type": "balance", "amount": "1000.00" }, "to": { "symbol": "EUR", "type": "bank", "bank_account": { "via": "sepa", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX" } } } ``` The API rejects a bank destination when `to.symbol` does not match its rail, or when a required bank field is missing. Create, get, and list responses use the same `from` and `to` fields. Each response includes the amount debited from the balance, the amount delivered to the destination, and the current settlement status for both sides. Wallet destinations may include a saved `memo` or `tag`; when saved, those fields are returned in the create response and in later get and list responses. Bank destinations return their rail-specific `bank_account` details. ```json theme={null} { "data": { "id": "00000000-0000-0000-0000-000000000009", "status": "converting", "from": { "symbol": "USD", "amount": "1000.00", "type": "balance", "status": "pending" }, "to": { "symbol": "ETH", "amount": "0.40000000", "type": "wallet", "status": "pending", "wallet": { "via": "ethereum", "address": "0xabcdef1234567890abcdef1234567890abcdef12", "memo": "destination-memo", "tag": "destination-tag" } }, "rate": { "base": { "symbol": "USD" }, "quote": { "symbol": "ETH" }, "price": "0.0004" }, "fees": { "from": { "symbol": "EUR", "amount": "0.50" }, "trade": { "symbol": "EUR", "amount": "0.25" }, "to": { "symbol": "BTC", "amount": "0.00001" } }, "created_at": "2026-07-31T00:00:00Z", "updated_at": "2026-07-31T00:00:00Z" } } ``` The required `Idempotency-Key` is a UUID for the create request. It must be unique. If the key was already used, the API returns a conflict error and does not create a new conversion. ### Monitor an on-demand conversion Use `GET /v1/accounts/{account_id}/on-demand-conversions/{conversion_id}` for one conversion, or `GET /v1/accounts/{account_id}/on-demand-conversions` to list conversions. The list is ordered by `created_at` descending. It uses cursor pagination with a default page size of 20 and a maximum page size of 25. The lifecycle status has these meanings: | Status | Meaning | | ------------ | ---------------------------------------------------------- | | `converting` | The conversion is in progress. | | `settling` | The converted asset is being delivered to the destination. | | `completed` | The conversion completed successfully. | | `failed` | The conversion could not be completed. | Create, get, and list responses use the same conversion details. `from.status` and `to.status` show the current settlement status for each side: `pending` means the side is still processing, `held` means it is waiting for review or release, `settled` means it completed successfully, and `failed` means it will not settle. Wallet destinations include their network and address. Bank destinations include their rail-specific `bank_account` details. Fees are always returned under `fees`; `transaction_references` are included when available. Saved wallet `memo` or `tag` fields are returned when present. The rate is an exchange rate. `rate.base.symbol` and `rate.quote.symbol` identify the rate pair, and `rate.price` is the price of one unit of the base asset denominated in the quote asset. For example, a price of `0.0004` with USD as the base and ETH as the quote means one USD converts to `0.0004` ETH. ## Creating a rule — fiat on-ramp (EUR → USDC on Polygon) An end customer sends EUR via SEPA and automatically receives USDC on Polygon at the destination wallet you specify. On the `from` (source) side you supply only `symbol`, `type`, and the rail (`bank_account.via` / `wallet.via`). The inbound routing is provisioned by PWS and returned on the response — any routing fields you send on `from` are ignored. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "name": "EUR autoramp to Polygon vault", "from": { "symbol": "EUR", "type": "bank", "bank_account": { "via": "sepa" } }, "to": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "polygon", "address": "0x1234567890abcdef1234567890abcdef12345678" } } }' ``` The response is the created rule. PWS populates `from.bank_account` with the provisioned virtual IBAN and BIC: ```json theme={null} { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "EUR autoramp to Polygon vault", "from": { "symbol": "EUR", "type": "bank", "bank_account": { "via": "sepa", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX" } }, "to": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "polygon", "address": "0x1234567890abcdef1234567890abcdef12345678" } }, "status": "active", "created_at": "2026-05-25T10:30:00Z", "updated_at": "2026-05-25T10:30:00Z" } } ``` Share `from.bank_account.iban` and `from.bank_account.bic` with the end customer as their payment instructions. Every SEPA credit to that IBAN fires the rule and produces a new conversion transaction. **Provisioning:** For some fiat rails the inbound account is provisioned asynchronously. The rule is returned with `status: provisioning` and the `from` routing fields are populated once provisioning completes (the rule then moves to `active`). Fetch the rule with `GET /v1/accounts/{account_id}/conversions/{conversion_rule_id}` to read the final routing. ## Creating a rule — crypto off-ramp (USDC on Ethereum → EUR via SEPA) An end customer sends USDC on Ethereum and automatically receives EUR via SEPA to the bank account you specify. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "name": "USDC off-ramp to SEPA", "from": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "ethereum" } }, "to": { "symbol": "EUR", "type": "bank", "bank_account": { "via": "sepa", "iban": "DE89370400440532013000", "bic": "DEUTDEFFXXX", "bank": "Deutsche Bank" } } }' ``` On the response, `from.wallet.address` carries the provisioned Ethereum deposit address. Share it with the end customer — any USDC deposit to that address triggers the rule and initiates the SEPA payout. **Fiat destinations:** The beneficiary on a fiat destination is the account's KYC identity — third-party bank accounts are not permitted. The beneficiary is derived from KYC, so there is no beneficiary field on the request. ## Supported rails and networks Bank destinations specify a `bank_account.via` and the routing fields that rail requires; crypto destinations specify a `wallet.via` (network) and `address`: | `via` | `type` | Required destination fields | | --------------------------------------------------------------- | -------- | ------------------------------------------------- | | `sepa` | `bank` | `iban` (+ optional `bic`, `bank`) | | `fps` | `bank` | `sort_code`, `account_number` (+ optional `bank`) | | Any crypto network (`bitcoin`, `ethereum`, `polygon`, `xrp`, …) | `wallet` | `address` (+ optional `tag` / `memo`) | | `ach`, `rtp` (linked account) | `bank` | `account_link_id` (see below) | The optional `bank` field on a destination named by routing details is the name of the beneficiary's financial institution; it is passed through to the underlying provider. A destination naming a linked account ignores it — the bank is already known from the link. The conversion rules API supports `sepa` and `fps` for bank destinations named by routing details. An unsupported `via` returns `400`. **Naming a bank account by its routing details is limited to SEPA and FPS.** Other rails named this way, including Fedwire, are rejected with `400`. ### US bank destinations A US bank account is reached a different way: link it once, then name it by its identifier instead of its routing details. Send `type: "bank"`, `symbol: "USD"`, the rail in `bank_account.via` (`ach` or `rtp`), and the `account_link_id` of a linked account inside that same `bank_account` block. ```json theme={null} "to": { "symbol": "USD", "type": "bank", "bank_account": { "via": "ach", "account_link_id": "al_7cec1ea9-f52e-4766-b02b-6113eaaa4d4e" } } ``` One link can be eligible for more than one rail, so `via` chooses which one the payout uses. Leaving it out is a `400`, and any value other than `ach` or `rtp` is rejected. See [Bank links](/tabs/developer-documentation/payments/bank-links) for how to link an account and get that identifier. The two shapes are mutually exclusive: a `bank_account` carries either routing details or an `account_link_id`, never both. ### Crypto destination tags and memos Networks that need a destination tag or memo to route a deposit accept them as optional fields on the wallet destination — the XRP destination `tag`, or the `memo` used by chains such as XLM, EOS, and Cosmos: ```json theme={null} "to": { "symbol": "XRP", "type": "wallet", "wallet": { "via": "xrp", "address": "rXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "tag": "1234567" } } ``` Omit `tag` / `memo` for networks that don't use them. ## Multi-network support A rule matches a single asset on a single network. Accepting the same asset on multiple networks requires one rule per network. Each rule gets its own provisioned inbound address and can be paused or deleted independently — keeping reconciliation and per-network reporting straightforward. To accept USDC on Ethereum and Polygon, create two rules identical except for `from.wallet.via`: ```bash theme={null} # Rule 1: USDC on Ethereum "from": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "ethereum" } } # Rule 2: USDC on Polygon "from": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "polygon" } } ``` ## Rule lifecycle | State | Meaning | | -------------- | ------------------------------------------------------------------------------------- | | `provisioning` | Inbound endpoint is being provisioned. Transitions to `active` once routing is ready. | | `active` | Rule is live. Matching deposits are processed normally. | | `paused` | Deposits are not processed. Can be reactivated. Provisioned credentials remain valid. | Deleting a rule is a **soft delete**: after `DELETE /v1/accounts/{account_id}/conversions/{conversion_rule_id}`, the rule no longer fires and a subsequent `GET` returns `404 Not Found`. Deleted rules are never returned by `listConversionRules`. ## Managing a rule Update a rule's destination, label, or status with `PUT /v1/accounts/{account_id}/conversions/{conversion_rule_id}`. This is a **full-state replacement** of `to`, `name`, and `status` — the source side (`from`) is immutable, so re-create the rule to change input symbol, rail, or source type. `status` must be `active` or `paused`. ```bash theme={null} # Pause a rule curl -X PUT "https://api.services.payward.com/v1/accounts/WVSD33HRMGSZUBM7/conversions/550e8400-e29b-41d4-a716-446655440000" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "name": "EUR autoramp to Polygon vault", "status": "paused", "to": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "polygon", "address": "0x1234567890abcdef1234567890abcdef12345678" } } }' # Delete (soft delete) a rule curl -X DELETE "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions/550e8400-e29b-41d4-a716-446655440000" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" ``` ## Monitoring conversions Each firing of a rule is a **conversion transaction**. List the transactions for a rule with `GET /v1/accounts/{account_id}/conversions/{conversion_rule_id}/transactions`: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions/550e8400-e29b-41d4-a716-446655440000/transactions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" ``` ```json theme={null} { "data": [ { "id": "conv_smoke_001", "status": "completed", "conversion_rule_id": "550e8400-e29b-41d4-a716-446655440000", "from": { "symbol": "EUR", "amount": "100.00", "type": "bank", "via": "sepa", "status": "settled" }, "to": { "symbol": "USDC", "amount": "108.42", "via": "polygon", "status": "settled" }, "chain_references": { "deposit_txid": "deposit-eur-001", "withdraw_txid": "withdraw-usdc-001" }, "created_at": "2026-04-01T12:00:00Z" }, { "id": "conv_smoke_002", "status": "pending_deposit", "conversion_rule_id": "550e8400-e29b-41d4-a716-446655440000", "chain_references": {}, "created_at": "2026-04-02T09:30:00Z" } ] } ``` A transaction moves through the following states: | State | Meaning | | ----------------- | --------------------------------------------------------------------- | | `pending_deposit` | Awaiting the inbound deposit. | | `held` | Deposit received but held pending review; conversion has not started. | | `converting` | Funds received; conversion in progress. | | `settling` | Conversion complete; outbound settlement in progress. | | `completed` | Settled to the destination. `chain_references` carry the txids. | | `failed` | The transaction failed. | **Webhooks.** Subscribe to conversion webhook events for real-time updates instead of polling — register your endpoint with the [Register Webhook](/api-reference/webhooks/register-webhook) API. See [Conversion webhooks](#conversion-webhooks). You can also list an account's rules and filter by lifecycle state: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions?status=active&page_size=20" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" ``` `listConversionRules` supports only `status`, `page_size`, and `page_token` (cursor) parameters. Use the per-rule transactions endpoint above for transaction history. ## Conversion webhooks Conversion lifecycle webhook payloads include optional enrichment fields. They are omitted when the source event does not carry the corresponding fact. | Event | Optional enrichment fields | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `conversion.deposit_completed` | `deposit_amount`, `deposit_currency`, `network`, `deposit_reference` | | `conversion.deposit_failed` | `error_code`, `deposit_amount`, `deposit_currency` | | `conversion.deposit_held` | `deposit_amount`, `deposit_currency` | | `conversion.quote_execution_failed` | `error_code`, `source_amount`, `source_currency`, `destination_currency` | | `conversion.travel_rule_blocked` | `error_code`, `source_amount`, `source_currency`, `destination_currency` | | `conversion.withdrawal_completed` | `withdrawal_amount`, `withdrawal_currency`, `destination_address`, `network`, `chain_references.blockchain_transaction_id` | | `conversion.withdrawal_failed` | `error_code`, `withdrawal_amount`, `withdrawal_currency`, `destination_address` | `error_code`, when present, is one of: `amount_too_small`, `quote_expired`, `destination_rejected`, `wallet_not_whitelisted`, `wallet_verification_method_not_supported`, `travel_rule_data_missing`, or `unknown`. `destination_address` is the crypto destination only. It is never a fiat destination, tag, or memo. `chain_references.blockchain_transaction_id` is the on-chain transaction id or hash for a completed crypto withdrawal. It is distinct from `withdraw_txid`. ### Resolving a Travel Rule block `conversion.travel_rule_blocked` means a pre-trade Travel Rule check stopped the conversion before any quote was executed, because the destination wallet needs address-ownership verification (or the check could not be evaluated). The `error_code` tells you how to resolve it: * `wallet_not_whitelisted` — the destination address is not yet ownership-verified. Whitelist it with `POST /v1/users/{user_id}/travel-rule/verifications` (hosted declaration or self-attestation); once the address is verified, subsequent conversions to it will proceed. * `wallet_verification_method_not_supported` — the address requires Satoshi-test or digital-signature verification, which is not available via the API yet. Contact Support to resolve. * `travel_rule_data_missing` — eligibility could not be evaluated because required account information (for example, the user's country) is missing. Complete the user's profile and retry; contact Support if the block persists. ## Whitelisting external wallets Before a conversion can pay out to an external crypto address, that address must pass a Travel Rule address-ownership check. Whitelist it with `POST /v1/users/{user_id}/travel-rule/verifications`: use `method: hosted_declaration` when the wallet is held by another custodial service, or `method: self_attestation` (supplying the end user's ownership evidence) when the end user controls the wallet. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/users/AA55N84GQOIRA67A/travel-rule/verifications" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "method": "hosted_declaration" }' ``` ```json theme={null} { "data": { "verification_id": "ver_0195e4d2-a4d0-7b12-8f4a-123456789abc", "wallet_type": "hosted", "method": "hosted_declaration", "status": "verified" } } ``` Satoshi-test and digital-signature verification are not available via the API yet; contact Support for addresses that require them. ## Error handling ### On-demand conversion validation The on-demand create endpoint returns `400 Bad Request` with `code: bad_request` when validation fails. This includes invalid or missing amounts, unsupported assets, invalid wallet details, unsupported bank rails, a bank rail and destination-currency mismatch, or missing rail-specific bank fields. The `causes[].field` value identifies the request field to correct. For example, a SEPA request without an IBAN identifies `to.bank_account.iban`: ```json theme={null} { "error": { "type": "conversions_error", "status": 400, "code": "bad_request", "causes": [ { "field": "to.bank_account.iban", "message": "'to.bank_account.iban' is required" } ] } } ``` ### Missing or invalid off-ramp bank details When you create or update an off-ramp rule (crypto → fiat), the `to.bank_account` fields are validated against the chosen rail before the rule goes live. If a field the rail requires is missing or malformed, the request is rejected with `400 Bad Request` and `code: conversion_invalid_withdrawal_address`. Each `causes[]` entry names the offending field with a `to.bank_account.*` path so you know exactly which value to supply. ```json theme={null} { "error": { "type": "conversions_error", "status": 400, "code": "conversion_invalid_withdrawal_address", "instance": "req_01H00000000000000000000000", "causes": [ { "field": "to.bank_account.bic", "message": "bic is required for this withdrawal method" } ] } } ``` The exact set of required fields depends on the rail and the provider routing the payout. Fields listed as optional in [Supported rails and networks](#supported-rails-and-networks) may still be required for a specific route — for example, some SEPA payout providers require `bic`. Populate every field the rejection names and resubmit. ## API reference | Endpoint | Description | | ----------------------------------------------------------------------------- | ------------------------------------------------------------- | | `POST /v1/accounts/{account_id}/conversions` | Create a conversion rule with a provisioned inbound endpoint | | `GET /v1/accounts/{account_id}/conversions` | List an account's conversion rules (filter by `status`) | | `GET /v1/accounts/{account_id}/conversions/{conversion_rule_id}` | Get a single conversion rule by ID | | `PUT /v1/accounts/{account_id}/conversions/{conversion_rule_id}` | Update a rule's `to`, `name`, and `status` (full replacement) | | `DELETE /v1/accounts/{account_id}/conversions/{conversion_rule_id}` | Soft-delete a conversion rule | | `GET /v1/accounts/{account_id}/conversions/{conversion_rule_id}/transactions` | List the transactions produced by a rule | | `POST /v1/accounts/{account_id}/on-demand-conversions` | Create an on-demand conversion | | `GET /v1/accounts/{account_id}/on-demand-conversions` | List on-demand conversions for an account | | `GET /v1/accounts/{account_id}/on-demand-conversions/{conversion_id}` | Get an on-demand conversion by ID | | `POST /v1/users/{user_id}/travel-rule/verifications` | Whitelist an external wallet address (Travel Rule) | **Conversion rules:** The following are planned but not exposed by the API today: a fee breakdown / executed rate / swap-quote on rules, a structured `failure_reason`, and an `expires_at` on inbound deposit addresses. On-demand conversion responses have a separate rate field and may include fees and transaction references; see [On-demand conversions](#on-demand-conversions). # Deposits and withdrawals Source: https://docs.services.payward.com/tabs/developer-documentation/payments/deposits-and-withdrawals This guide covers cryptocurrency and fiat deposits, plus cryptocurrency withdrawals, through the Payward Services API. ## Prerequisites * Payward Services API credentials (see [Authentication Guide](/tabs/developer-documentation/get-started/authentication)) * A verified user with at least one account Deposit addresses are issued by Payward and managed under `/v1/accounts/{account_id}/funds/deposits/addresses`. Withdrawals use user-added funding addresses managed under `/v1/accounts/{account_id}/funds/addresses`; register a destination once and pass its `address_id` when creating a withdrawal. ## Deposits ### Deposit workflow Query available deposit methods for the target asset. `GET /v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}` If the selected method's `activation_status` is `needed`, request activation once. List the methods again while the status is `pending` and continue when it is `completed`. `POST /v1/accounts/{account_id}/funds/deposits/methods/{method_id}/activation` Retrieve the crypto addresses or fiat bank-transfer instructions available for the selected method. `GET /v1/accounts/{account_id}/funds/deposits/addresses` For crypto methods, claim an address if no suitable address is available. Fiat methods do not use this operation; after selecting a usable method, retrieve its bank-transfer instructions from the list operation. `POST /v1/accounts/{account_id}/funds/deposits/addresses` For crypto, display the address and any required tag or memo. For fiat, display every bank-transfer field returned for the selected method. Subscribe to `funds.deposit_*` webhook events, then reconcile deposits on the receiving account through `GET /v1/accounts/{account_id}/portfolio/transactions?types=deposit`. Bitcoin Lightning uses one-time, amount-bound invoices instead of reusable deposit addresses. After listing methods, replace the address steps above with the [Bitcoin Lightning invoice flow](#bitcoin-lightning-invoice-flow). The examples below use the signing helpers from the [Authentication Guide](/tabs/developer-documentation/get-started/authentication). For GET requests, sign the same ordered query parameters that you send. Addresses and memos in the examples are illustrative. Do not send funds to them. ### Step 1: list deposit methods Query available deposit methods for an asset. Use the method's `id` as `method_id` when listing deposit destinations or claiming a crypto address. For fiat methods, continue when `activation_status` is `not_needed` or `completed`, or when the field is omitted. When it is `needed`, activate the method once in Step 2. When it is `pending`, do not send another activation request; list the methods again later or offer another usable method. Most cryptocurrency deposits are free, with minimum deposit amounts varying by asset. A few cryptocurrencies are charged an `address_setup_fee` (a one-time fee on the user's first deposit to a new address) or a per-deposit `fee`. ```python Python theme={null} def list_deposit_methods(account_id, asset_symbol): endpoint = f"/v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}" nonce = time.time_ns() signature = get_payward_signature(endpoint, None, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, ) return response.json() methods = list_deposit_methods(account_id, "BTC") for m in methods["data"]: print(f"{m['network']} (id: {m['id']})") ``` ```javascript Javascript theme={null} async function listDepositMethods(accountId, assetSymbol) { const endpoint = `/v1/accounts/${accountId}/funds/deposits/methods/${assetSymbol}`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, }, }); return response.json(); } const methods = await listDepositMethods(accountId, 'BTC'); for (const m of methods.data) { console.log(`${m.network} (id: ${m.id})`); } ``` Use `page_token` from `next_page_token` to request the next page. Omit it for the first page. #### Response example ```json theme={null} { "data": [ { "id": "2fa11f79-eeba-4d4e-afda-029abff6e29e", "network": "Bitcoin", "network_info": { "explorer": "https://mempool.space/tx/", "confirmations": "3", "est_confirmation_time": "45" }, "minimum": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.0001" } } ] } ``` Key fields to display to users are `network`, `method` when present, `fee`, `minimum`, `activation_status`, and `network_info.est_confirmation_time`. Pass the method's `id` back as `method_id` when activating a method, listing deposit instructions, or claiming a crypto address. Optional fee, limit, and network fields are omitted when they do not apply. For fiat methods, `supports_virtual_accounts: true` means Funding can provide dedicated virtual-account instructions. `false` does not identify a particular alternative flow, and omission means Funding did not return deposit metadata for the method. Use the deposit-instructions response, not this boolean, to determine which banking fields and payment reference are available. Crypto assets can have a maximum of five deposit addresses. Some crypto funding methods share an address space, so the same address can be returned for different assets or networks. `method_id` selects a context in which the returned address is valid; it does not say how the address was originally claimed. List and reuse an existing address before claiming another. ### Step 2: activate a deposit method when required If the selected method's `activation_status` is `needed`, request activation using its `id` as `method_id`. A `204` response means Funding accepted the request; it does not mean activation has necessarily completed, and it does not contain deposit instructions. ```python Python theme={null} def activate_deposit_method(account_id, method_id): endpoint = ( f"/v1/accounts/{account_id}/funds/deposits/methods/" f"{method_id}/activation" ) nonce = time.time_ns() signature = get_payward_signature(endpoint, None, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, } response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, ) response.raise_for_status() activate_deposit_method( account_id, "5e764f4c-f924-47f0-9fcb-6f98c37d1575", ) ``` ```javascript Javascript theme={null} async function activateDepositMethod(accountId, methodId) { const endpoint = `/v1/accounts/${accountId}/funds/deposits/methods/${methodId}/activation`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, }, }); if (!response.ok) { throw new Error(`Activation failed with status ${response.status}`); } } await activateDepositMethod(accountId, '5e764f4c-f924-47f0-9fcb-6f98c37d1575'); ``` After the request is accepted, list deposit methods again. Continue to Step 3 when the selected method reports `completed`. If it reports `pending`, wait and list the methods again later. Activation returns `400 Bad Request` when the selected account is missing verification information or documents required by the funding method. The error is associated with `account_id`; when available, its message names the missing requirement, such as `address`. If the activation request times out or its outcome is unclear, list the deposit methods again before taking further action. Do not retry if the activation status is `pending` or `completed`. ### Step 3: list deposit addresses Retrieve the crypto addresses or fiat bank-transfer instructions available for the selected funding method. Reuse a returned crypto address instead of creating a new one each time. ```python Python theme={null} def list_deposit_addresses(account_id, method_id, page_token=None): endpoint = f"/v1/accounts/{account_id}/funds/deposits/addresses" params = { "method_id": method_id, } if page_token: params["page_token"] = page_token nonce = time.time_ns() signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, params=params, ) return response.json() addresses = list_deposit_addresses( account_id, "2fa11f79-eeba-4d4e-afda-029abff6e29e" ) for item in addresses["data"]: details = item["address_details"] if "crypto" in details: crypto = details["crypto"] print(f"Crypto address: {crypto['address']}") else: fiat = details["fiat"] print(f"Fiat instructions: {fiat}") ``` ```javascript Javascript theme={null} async function listDepositAddresses(accountId, methodId, pageToken = null) { const endpoint = `/v1/accounts/${accountId}/funds/deposits/addresses`; const params = { method_id: methodId }; if (pageToken) params.page_token = pageToken; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const searchParams = new URLSearchParams(params); const url = `${BASE_URL}${endpoint}?${searchParams.toString()}`; const response = await fetch(url, { method: 'GET', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, }, }); return response.json(); } const addresses = await listDepositAddresses(accountId, '2fa11f79-eeba-4d4e-afda-029abff6e29e'); for (const item of addresses.data) { if (item.address_details.crypto) { console.log('Crypto address:', item.address_details.crypto.address); } else { console.log('Fiat instructions:', item.address_details.fiat); } } ``` #### Response example ```json theme={null} { "data": [ { "address_details": { "crypto": { "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" } }, "expire_time": "2026-08-11T09:20:00Z" }, { "address_details": { "fiat": { "bank": "Banking Circle", "iban": "GB29NWBK60161331926819", "bic": "NWBKGB2L", "name_on_account": "Payward Ltd", "reference": "AA71 N84G 7JER CJPQ" } } } ] } ``` Every item has one `address_details` variant: * `crypto.address` is required for crypto destinations. `crypto.tag` and `crypto.memo` are present only when the network requires them. * The `fiat` object contains only the banking fields that apply to the selected method. Fields such as `iban`, `account_number`, `routing_number`, `sort_code`, `bic`, `swift`, `name_on_account`, and `reference` are optional individually. When `reference` is present, include it with the fiat deposit. * `expire_time` is present only when the destination expires. Use `page_token` from `next_page_token` to retrieve the next page. Completed deposits appear in `GET /v1/accounts/{account_id}/portfolio/transactions?types=deposit`. Portfolio visibility is asynchronous. ### Step 4: claim a crypto deposit address If Step 3 returns no suitable crypto address, claim one using the method `id` from Step 1 as `method_id`. Fiat deposit methods return their instructions from Step 3 and cannot be claimed with this operation. You can claim a maximum of five deposit addresses per asset. List the account's existing addresses before claiming another one. ```python Python theme={null} import json def claim_deposit_address(account_id, method_id): endpoint = f"/v1/accounts/{account_id}/funds/deposits/addresses" body = json.dumps({"method_id": method_id}, separators=(",", ":")) nonce = time.time_ns() signature = get_payward_signature(endpoint, body, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, "Content-Type": "application/json", } response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, data=body, ) return response.json() address = claim_deposit_address( account_id, "2fa11f79-eeba-4d4e-afda-029abff6e29e", ) print(f"Deposit address: {address['data']['address']}") ``` ```javascript Javascript theme={null} async function claimDepositAddress(accountId, methodId) { const endpoint = `/v1/accounts/${accountId}/funds/deposits/addresses`; const body = JSON.stringify({ method_id: methodId }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', }, body, }); return response.json(); } const address = await claimDepositAddress(accountId, '2fa11f79-eeba-4d4e-afda-029abff6e29e'); console.log('Deposit address:', address.data.address); ``` ```json theme={null} { "data": { "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" } } ``` Address claims are not idempotent. Do not retry automatically after a timeout or another response with an unknown outcome. List the account's addresses before deciding whether to make another claim. Some networks (e.g., XRP, XLM) require a tag or memo in addition to the address. If `tag` or `memo` is present in the response, your UI must display it and instruct the user to include it when sending funds. Deposits sent without the required tag/memo may be lost. ### Bitcoin Lightning invoice flow For the Bitcoin Lightning deposit method, create a BOLT11 invoice for the exact BTC amount the user intends to deposit. Use the limits from the deposit-methods response to validate the amount before requesting the invoice. The selected `account_id` is the account that receives the completed deposit. ```python Python theme={null} def create_lightning_invoice(account_id, amount): endpoint = f"/v1/accounts/{account_id}/funds/deposits/lightning-invoices" body = json.dumps( {"asset_symbol": "BTC", "amount": amount}, separators=(",", ":"), ) nonce = time.time_ns() signature = get_payward_signature(endpoint, body, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, "Content-Type": "application/json", } response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, data=body, ) return response.json() lightning = create_lightning_invoice(account_id, "0.0005") print(f"Invoice: {lightning['data']['invoice']}") print(f"Expires at: {lightning['data'].get('expires_at')}") ``` ```javascript Javascript theme={null} async function createLightningInvoice(accountId, amount) { const endpoint = `/v1/accounts/${accountId}/funds/deposits/lightning-invoices`; const body = JSON.stringify({ asset_symbol: 'BTC', amount }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', }, body, }); return response.json(); } const lightning = await createLightningInvoice(accountId, '0.0005'); console.log('Invoice:', lightning.data.invoice); console.log('Expires at:', lightning.data.expires_at); ``` ```json theme={null} { "data": { "invoice": "lnbc500u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sqdqqcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqjcewm5cjwz4a6rfjx77c490yced6pemk0upkxhy89cmm7sct66k8gneanwykzgdrwrfje69h9u5u0w57rrcsysas7gadwmzxc8c6t0spjazup6", "expires_at": "2026-08-03T12:34:56Z" } } ``` A Lightning invoice is single-use, must be paid in full, and cannot be paid after `expires_at`. Invoice creation is not idempotent. If a request has an unknown outcome, create a new invoice; unpaid invoices expire on their own. ### Reusable-address UI flow ```mermaid theme={null} flowchart LR A[Show Available Cryptocurrencies] --> B[Show Available Methods] B --> C[Show Available Addresses] C --> D[Create New Address] D --> C C --> E[Display Address text or QR code] ``` ### Best practices 1. **Always display tag/memo:** For networks that require a tag or memo (XRP, XLM, etc.), prominently display it alongside the address. Missing tags/memos can result in lost funds. 2. **Set expectations:** Show `minimum` amounts and `est_confirmation_time` from the methods response so users know what to expect before sending funds. 3. **Use fresh responses:** Available methods, addresses and limits are user-specific and may change based on account standing, remaining limits, or regional regulations. Fetch fresh data before displaying options rather than relying on cached results. ### Deposit webhook events Register a webhook through `POST /v1/webhooks` and subscribe to the deposit lifecycle events your integration needs: | Event | Meaning | | ------------------------ | -------------------------------------------------------- | | `funds.deposit_received` | The deposit was received and awaits confirmations/checks | | `funds.deposit_held` | The deposit is held pending review or additional checks | | `funds.deposit_credited` | The deposit was credited and can now be used | | `funds.deposit_returned` | The deposit was returned to its source | | `funds.deposit_failed` | The deposit failed and will not be credited | All deposit lifecycle events contain `type`, `transaction_id`, `user_id`, and `account_id`. They can also contain `amount` and `fee`: ```json theme={null} { "amount": { "asset": "USD", "value": "100.00" }, "fee": { "asset": "USD", "value": "0" } } ``` `amount` is the total deposit amount, inclusive of the fee. PWS includes it when both the source value and its asset can be resolved. `fee` is optional. PWS includes it only when the source provides both the fee value and its asset and the asset can be resolved. This includes an explicitly provided zero fee. If the fee or its asset is unavailable, PWS omits `fee` rather than assuming that it is zero or that it uses the deposit amount's asset. The availability of `amount` and `fee` is independent of the lifecycle event type. Treat absent fields as unavailable; PWS does not return them as `null`. Returned and failed deposits also contain a required `reason` field. PWS normalizes it to one of `account_details_mismatch`, `limit_exceeded`, `duplicate`, `account_restricted`, or `other`. Other deposit lifecycle events do not contain `reason`. A deposit can produce multiple lifecycle events as it progresses. Treat each webhook as a notification and use the Portfolio Transactions endpoint for reconciliation. Deliveries can be repeated, so use the `svix-id` header as the deduplication key and return a 2xx response after accepting the event. These events describe the lifecycle of a deposit. They are distinct from `conversion.deposit_*` events, which describe the inbound funding leg of a conversion. ## Crypto withdrawals Create a normal crypto withdrawal to a saved funding address. Funding calculates the fee and validates the method, network, destination, limits, activation, account state, and Travel Rule requirements. Quote the fee first when you need to show it to a user before they commit. ### Withdrawal workflow Choose a method or network and inspect its limits and required `address_fields`. `GET /v1/accounts/{account_id}/funds/withdrawals/methods/{asset_symbol}` List and reuse a compatible `address_id`. Register a destination first when no suitable address exists. `GET /v1/accounts/{account_id}/funds/addresses` `POST /v1/accounts/{account_id}/funds/addresses` Get the fee for a gross `amount`, plus the short-lived `fee_token` you carry into the next step. `POST /v1/accounts/{account_id}/funds/fees` Submit the selected scope, saved `address_id`, public asset `symbol`, and gross `amount`. `POST /v1/accounts/{account_id}/funds/withdrawals` If the request returns `withdrawal_verification_required`, complete a supported Travel Rule verification method. Use the returned `withdrawal_id` as `reference.id` with `reference.kind=funding`. To safely retry a withdrawal after a timeout or another unknown outcome, generate a UUIDv4 `Idempotency-Key` and reuse the same key for each retry. The header is optional. If you omit it, each request is treated as a new withdrawal request. Exact Portfolio reconciliation requires the `withdrawal_id` returned by a successful create response. ### Step 1: list withdrawal methods Use each method's `id` as `scope.method_id`, or its `network_id` as `scope.network_id`. Network scope succeeds only when Funding can resolve exactly one eligible method for the requested asset. Otherwise, retry with a specific method ID. `fee`, `minimum`, `maximum`, and `period_limits` are informational and can change before submission. To get fee terms for a specific amount that you can carry into withdrawal creation, quote them in Step 3. ```json theme={null} { "data": [ { "id": "2fa11f79-eeba-4d4e-afda-029abff6e29e", "network_id": "6d7f9c7e-588d-4e56-a6f7-f1e9ada7e639", "network": "Bitcoin", "fee": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00005000" }, "minimum": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00050000" }, "address_fields": [ { "key": "address", "required": true } ] } ] } ``` ### Step 2: select or create a funding address List the account's funding addresses and reuse a compatible destination. Register a new address only when no suitable address exists. Funding-address creation is not idempotent. If the request has an unknown outcome, list the account's funding addresses before creating another one. Do not automatically retry. #### List existing funding addresses The first request can include `page_size`. To continue, send the returned `page_token` and the same `method_id` when filtering. Omit `page_size`; the token preserves it. The token is bound to the selected account and method filter. Pass `method_id` to filter by that method's scope; omitting it returns every funding address on the account. Because scopes nest (method → network → network group), a matching address may be registered more broadly and report a `network_id` or `network_group_id` in `scope` instead of the `method_id` you filtered on. An empty `method_id` is rejected as a validation error, not treated as no filter. ```python Python theme={null} def list_funding_addresses(account_id, method_id=None, page_size=20, page_token=None): endpoint = f"/v1/accounts/{account_id}/funds/addresses" params = {"page_token": page_token} if page_token else {"page_size": page_size} if method_id is not None: params["method_id"] = method_id nonce = time.time_ns() signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, } response = requests.get( f"{BASE_URL}{endpoint}", headers=headers, params=params, ) return response.json() ``` ```javascript Javascript theme={null} async function listFundingAddresses(accountId, methodId = null, pageSize = 20, pageToken = null) { const endpoint = `/v1/accounts/${accountId}/funds/addresses`; const params = pageToken ? { page_token: pageToken } : { page_size: pageSize }; if (methodId !== null) params.method_id = methodId; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, params); const query = new URLSearchParams(params); const response = await fetch(`${BASE_URL}${endpoint}?${query}`, { headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, }, }); return response.json(); } ``` ```json theme={null} { "data": [ { "address_id": "AB12345-6789A-BCDEFG", "scope": { "method_id": "2fa11f79-eeba-4d4e-afda-029abff6e29e" }, "name": "Primary BTC wallet", "address_details": { "crypto": { "address": "bc1qexampleaddress", "tag": "123", "memo": "456" } } } ], "next_page_token": "MjoyRHuBXSgoe0mDHt-iFALROW46EGdvk_trgja31Vwr7H4" } ``` #### Create a funding address Set exactly one scope: * Use `method_id` when the destination should be registered for one withdrawal method. * Use `network_id` when the destination should be valid for methods on that network. Funding validates the scope and address fields. Collect the fields identified by `address_fields` in the withdrawal-method response. Do not add local address-validation rules. ```python Python theme={null} import json def create_funding_address(account_id, scope, name, address, tag=None, memo=None): endpoint = f"/v1/accounts/{account_id}/funds/addresses" crypto = {"address": address} if tag: crypto["tag"] = tag if memo: crypto["memo"] = memo body = json.dumps( { "scope": scope, "name": name, "address_details": {"crypto": crypto}, }, separators=(",", ":"), ) nonce = time.time_ns() signature = get_payward_signature(endpoint, body, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, "Content-Type": "application/json", } response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, data=body, ) return response.json() ``` ```javascript Javascript theme={null} async function createFundingAddress(accountId, scope, name, address, tag = null, memo = null) { const endpoint = `/v1/accounts/${accountId}/funds/addresses`; const crypto = { address }; if (tag) crypto.tag = tag; if (memo) crypto.memo = memo; const body = JSON.stringify({ scope, name, address_details: { crypto }, }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', }, body, }); return response.json(); } ``` Successful creation returns `200`: ```json theme={null} { "data": { "address_id": "AB12345-6789A-BCDEFG" } } ``` The `name` must be unique per account and is limited to 64 characters. A create request can return these `409` codes: | Code | Meaning | | -------------------------------------------- | ----------------------------------------------------------------- | | `withdrawal_address_limit_reached` | The account reached the address limit for the selected scope. | | `withdrawal_address_already_exists` | A matching address or name already exists for the selected scope. | | `withdrawal_address_account_settings_locked` | Global Settings Lock temporarily prevents address changes. | Address limits vary by account and scope and are not exposed by this API. Reuse an existing address or contact support when the limit is reached. #### Get, rename, or delete a funding address Use the stable `address_id` in the member path: * `GET /v1/accounts/{account_id}/funds/addresses/{address_id}` returns the saved scope, name, and address details. * `PATCH /v1/accounts/{account_id}/funds/addresses/{address_id}` renames the address and returns `204`. * `DELETE /v1/accounts/{account_id}/funds/addresses/{address_id}` deletes the address and returns `204`. Get and delete return `404` when the address is absent or unavailable to the selected account. Update can return `403` when the caller is not allowed to change the address. Always enforce account-to-address ownership in your integration. ### Step 3: quote the withdrawal fee before you display it Quote the fee for the gross amount you intend to withdraw. Use a method `id` from Step 1 as `method_id`. Fiat methods succeed but omit `fee_token`, since their fees don't change between quote and withdrawal. Skip this step when you do not need to show a fee before submitting. `amount` is the gross amount, inclusive of the fee. ```python Python theme={null} import json def calculate_funding_fees(account_id, method_id, amount): endpoint = f"/v1/accounts/{account_id}/funds/fees" body = json.dumps( { "method_id": method_id, "amount": amount, }, separators=(",", ":"), ) nonce = time.time_ns() signature = get_payward_signature(endpoint, body, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, "Content-Type": "application/json", } response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, data=body, ) return response.json() quote = calculate_funding_fees( account_id, "2fa11f79-eeba-4d4e-afda-029abff6e29e", "0.01000000", ) ``` ```javascript Javascript theme={null} async function calculateFundingFees(accountId, methodId, amount) { const endpoint = `/v1/accounts/${accountId}/funds/fees`; const body = JSON.stringify({ method_id: methodId, amount, }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', }, body, }); return response.json(); } const quote = await calculateFundingFees(accountId, '2fa11f79-eeba-4d4e-afda-029abff6e29e', '0.01000000'); ``` A successful quote returns `201`: ```json theme={null} { "data": { "fee": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00005000" }, "gross_amount": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.01000000" }, "net_amount": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00995000" }, "fee_token": "eyJ3aXRoZHJhd2FsX2ZlZSI6Ii4uLiJ9" } } ``` `net_amount` is what reaches the destination, and for same-asset crypto fees `net_amount + fee` equals `gross_amount`. Each request returns a new quote and moves no funds. Pass `fee_token` to Step 4 to reuse this quote's fee terms. Treat the token as opaque and sensitive: do not parse or modify it, do not log it, and do not reuse it across users or accounts. The quote checks the method and the amount only. Funding still checks balance, method minimums, address compatibility, and Travel Rule requirements when you create the withdrawal. ### Step 4: create the withdrawal `amount` is the gross amount debited from the account, inclusive of the fee. For same-asset crypto fees, `net_amount + fee` equals `gross_amount`. To reuse the fee terms from Step 3, send its `fee_token` and set `scope.method_id` to the `id` of the method you quoted. Fee tokens cannot be used with network scope. Omit `fee_token` to have Funding calculate the fee when the withdrawal is created. Funding recalculates the absolute fee from the token's pinned terms and this request's `amount`. Submit the same amount as the quote to reproduce the fee you displayed. The token does not pin the amount. Submitting a different amount does not invalidate the token, but Funding then recalculates the fee for the amount you submit, so the fee charged differs from the fee you displayed. The request is still subject to the usual amount checks. Request a new quote whenever the amount changes. A `400` on `fee_token` means no withdrawal was created. Request a new quote and resubmit with its token. Do not resend a rejected token. ```python Python theme={null} import json def create_withdrawal(account_id, scope, address_id, symbol, amount, fee_token=None, idempotency_key=None): endpoint = f"/v1/accounts/{account_id}/funds/withdrawals" payload = { "scope": scope, "address_id": address_id, "symbol": symbol, "amount": amount, } if fee_token is not None: payload["fee_token"] = fee_token body = json.dumps(payload, separators=(",", ":")) nonce = time.time_ns() signature = get_payward_signature(endpoint, body, API_SECRET, nonce) headers = { "API-Key": API_KEY, "API-Nonce": str(nonce), "API-Sign": signature, "Content-Type": "application/json", } if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key response = requests.post( f"{BASE_URL}{endpoint}", headers=headers, data=body, ) return response.json() create_withdrawal( account_id, {"method_id": "2fa11f79-eeba-4d4e-afda-029abff6e29e"}, "AB12345-6789A-BCDEFG", "BTC", "0.01000000", fee_token=quote["data"]["fee_token"], idempotency_key="550e8400-e29b-41d4-a716-446655440000", ) ``` ```javascript Javascript theme={null} async function createWithdrawal(accountId, scope, addressId, symbol, amount, feeToken, idempotencyKey) { const endpoint = `/v1/accounts/${accountId}/funds/withdrawals`; const body = JSON.stringify({ scope, address_id: addressId, symbol, amount, ...(feeToken ? { fee_token: feeToken } : {}), }); const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(`${BASE_URL}${endpoint}`, { method: 'POST', headers: { 'API-Key': API_KEY, 'API-Nonce': nonce, 'API-Sign': signature, 'Content-Type': 'application/json', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, body, }); return response.json(); } await createWithdrawal( accountId, { method_id: '2fa11f79-eeba-4d4e-afda-029abff6e29e' }, 'AB12345-6789A-BCDEFG', 'BTC', '0.01000000', quote.data.fee_token, '550e8400-e29b-41d4-a716-446655440000', ); ``` Successful withdrawal creation returns `201`: ```json theme={null} { "data": { "withdrawal_id": "FTSBuuG-9BctU4m3Emt88submmZVKY", "net_amount": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00995000" }, "gross_amount": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.01000000" }, "fee": { "symbol": "BTC", "name": "Bitcoin", "type": "crypto", "amount": "0.00005000" } } } ``` ### Address-ownership verification When the response is `409 withdrawal_verification_required`, read `verification_methods`. Funding can return `self_attestation`, `satoshi_test`, or `digital_signature`. The PWS Travel Rule verification endpoint currently accepts `self_attestation` from this list. Complete it through `POST /v1/users/{user_id}/travel-rule/verifications`, then submit a new withdrawal request. Contact support when the response does not include a method that the endpoint supports. ### Reconcile the withdrawal List `GET /v1/accounts/{account_id}/portfolio/transactions?types=withdrawal`. Match the transaction whose `reference.kind` is `funding` and whose `reference.id` equals the returned `withdrawal_id`. Portfolio is the canonical status and history surface. Do not wait for a withdrawal lifecycle webhook; this API does not publish one. ### Withdrawal best practices 1. **Reuse saved addresses:** List funding addresses before creating another destination. 2. **Use the exact scope:** Prefer `method_id` when multiple methods exist on the same network. 3. **Quote the fee before you display it:** Method-list fees are estimates. Quote the fee for the amount you intend to withdraw, then send that quote's `fee_token` with the same amount. 4. **Reuse idempotency keys for retries:** Generate a UUIDv4 key before creating a withdrawal. Reuse it after an unknown outcome. If you omit the header, reconcile through Portfolio before submitting another withdrawal. 5. **Protect routing fields:** Preserve required tags and memos, and do not log complete destination details. ### Common errors | HTTP status | Cause | Remediation | | ----------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `400 Bad Request` | Invalid scope, address, symbol, amount, insufficient balance, or method limits | Correct the named field in `error.causes`; use a method ID when network scope is ambiguous | | `400 Bad Request` | Invalid or expired `fee_token`, or a fee token sent with `network_id` scope | Request a new quote; send its token with `scope.method_id` | | `400 Bad Request` | `method_id` is unknown or does not support fee quotes | Quote with a method `id` from Step 1 | | `401 Unauthorized` | Missing or invalid API credentials | Verify `API-Key`, `API-Nonce`, and `API-Sign` | | `403 Forbidden` | The caller is not allowed, or the funding method requires activation or unsupported step-up | Confirm permissions and select an available method | | `409 Conflict` | Address ownership verification or Global Settings Lock blocks the withdrawal | Complete self-attestation when returned; otherwise contact support, or wait for the lock | | `429 Too Many Requests` | Rate limit exceeded | Back off and retry according to the rate-limit headers | | `500 Internal` | Unexpected or malformed upstream response | Contact support; do not retry automatically | | `503 Unavailable` | A required service is temporarily unavailable | Treat the outcome as unknown and contact support | | `504 Deadline Exceeded` | The request deadline elapsed | Treat the outcome as unknown and contact support | ## API reference | Endpoint | Method | API reference | | ------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | | `/v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}` | GET | [List deposit methods](/api-reference/funds/list-deposit-methods) | | `/v1/accounts/{account_id}/funds/deposits/methods/{method_id}/activation` | POST | [Activate deposit method](/api-reference/funds/activate-deposit-method) | | `/v1/accounts/{account_id}/funds/deposits/addresses` | GET | [List deposit addresses](/api-reference/funds/list-deposit-addresses) | | `/v1/accounts/{account_id}/funds/deposits/addresses` | POST | [Claim deposit address](/api-reference/funds/claim-deposit-address) | | `/v1/accounts/{account_id}/funds/deposits/lightning-invoices` | POST | [Create Lightning invoice](/api-reference/funds/create-lightning-invoice) | | `/v1/accounts/{account_id}/funds/withdrawals/methods/{asset_symbol}` | GET | [List withdrawal methods](/api-reference/funds/list-withdrawal-methods) | | `/v1/accounts/{account_id}/funds/addresses` | GET | [List funding addresses](/api-reference/funds/list-funding-addresses) | | `/v1/accounts/{account_id}/funds/addresses` | POST | [Create funding address](/api-reference/funds/create-funding-address) | | `/v1/accounts/{account_id}/funds/addresses/{address_id}` | GET | [Get funding address](/api-reference/funds/get-funding-address) | | `/v1/accounts/{account_id}/funds/addresses/{address_id}` | PATCH | [Update funding address](/api-reference/funds/update-funding-address) | | `/v1/accounts/{account_id}/funds/addresses/{address_id}` | DELETE | [Delete funding address](/api-reference/funds/delete-funding-address) | | `/v1/accounts/{account_id}/funds/fees` | POST | [Calculate funding fees](/api-reference/funds/calculate-funding-fees) | | `/v1/accounts/{account_id}/funds/withdrawals` | POST | [Create withdrawal](/api-reference/funds/create-withdrawal) | | `/v1/accounts/{account_id}/portfolio/transactions` | GET | [List Portfolio transactions](/api-reference/portfolio/list-portfolio-transactions) | # Ramp Source: https://docs.services.payward.com/tabs/developer-documentation/payments/ramp The Payward Ramp API lets you embed a fiat-to-crypto on-ramp into your application via a hosted checkout flow. The Payward Ramp API lets you embed a fiat-to-crypto on-ramp into your application. Instead of building payment processing, KYC, and crypto delivery yourself, you call Ramp endpoints to discover what's available, preview pricing, and then generate a hosted checkout URL that handles the rest. Your user pays in fiat; Payward delivers crypto to the destination wallet. **Not what you're looking for?** Ramp is designed for end-user fiat-to-crypto purchases via a hosted checkout flow. If you need to move funds between PWS accounts (Transfers), convert assets programmatically without a checkout UI (Conversions), or execute institutional FX-style trades (Swap), see the [PWS API reference](/api-reference). ## How it works A typical Ramp integration follows four steps: Query supported countries, fiat currencies, payment methods, and cryptocurrency assets to know what options are valid for your user's location. Call the prospective quote endpoint to show the user an estimated pay/receive breakdown before they commit. Optionally verify that the user's intended amount falls within min/max bounds. Generate a hosted checkout URL and redirect the user to it. Payward handles payment processing and crypto delivery. ## Base URL ``` https://api.services.payward.com ``` ## Authentication All requests require two headers: | Header | Description | | ---------- | --------------------------------------------- | | `API-Key` | Your public API key | | `API-Sign` | HMAC signature computed with your private key | See [Authentication](/tabs/developer-documentation/get-started/authentication) for how to generate the signature. ## Step 1 — Discover supported options Before building your UI, query the discovery endpoints to understand what's valid for your user's country. All four endpoints are read-only and can be called at startup or cached. ### List supported countries ``` GET /v1/ramp/countries ``` Returns countries where Ramp is available. Countries that require regional filtering include a `subdivisions` array. Use the returned `country` value and any relevant `subdivision` value as inputs to all other Ramp endpoints. ### List fiat currencies ``` GET /v1/ramp/fiat-currencies ``` Returns fiat currencies supported for funding Ramp transactions (e.g., `USD`, `EUR`). Pass the returned codes as `from_symbol` in later calls. ### List payment methods ``` GET /v1/ramp/payment-methods ``` Returns available payment methods (e.g., `credit_card`). Pass the returned identifiers as `from_method`. ### List cryptocurrency assets ``` GET /v1/ramp/buy/crypto ``` Returns cryptocurrency assets available for purchase, along with their supported networks and withdrawal methods. Both `to_symbol` and `to_method` values for later calls come from here. All four discovery endpoints are cursor-paginated. Use `page_size` to set the first page size (default 50, maximum 200). If a response includes `next_page_token`, pass that value as `page_token` to fetch the next page. Page tokens are scoped to the original endpoint and country/subdivision where applicable, and the token's page size wins over a new `page_size` value. Query parameters: | Parameter | Required | Description | | ------------- | -------- | ---------------------------------------------------------------------------- | | `country` | Yes | Required for fiat currencies, payment methods, and cryptocurrency assets | | `subdivision` | No | ISO 3166-2 suffix from the countries response | | `page_size` | No | First-page size, from 1 to 200. Defaults to 50 | | `page_token` | No | Opaque token from `next_page_token` for the next page of the same result set | ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/ramp/buy/crypto?country=US&subdivision=CA&page_size=200" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" ``` ## Step 2 — Preview pricing Before sending your user to checkout, call the prospective quote endpoint to display an estimated cost breakdown. This call does **not** reserve liquidity — it's safe to call on every keystroke for a live price preview. ``` GET /v1/ramp/quotes/prospective ``` The `from_*` parameters describe the side the user pays with and the `to_*` parameters the side they receive — the same source/destination model the Conversions and Swap APIs use. Required parameters: | Parameter | Description | | ------------- | ---------------------------------------------------- | | `from_symbol` | Fiat currency ticker (e.g., `USD`) | | `from_method` | Payment method (e.g., `credit_card`) | | `from_amount` | Amount the user intends to spend | | `to_symbol` | Cryptocurrency asset ticker to receive (e.g., `BTC`) | | `to_method` | Withdrawal method (e.g., `Bitcoin`) | | `country` | User's country code | Optional parameters: | Parameter | Description | | ------------------- | --------------------------------------------------------------- | | `from_type` | Asset classification (`fiat`, `crypto`, `stablecoin`, `xstock`) | | `to_type` | Asset classification for the destination side | | `subdivision` | US state / CA province code | | `affiliate_fee_bps` | Your affiliate fee in basis points (e.g., `250` = 2.5%) | The response includes a `from` object (the side the user pays, fiat today) and a `to` object (the side they receive, crypto today), each with a full fee breakdown. Asset references use the `{ symbol, type, name }` shape: ```json theme={null} { "data": { "from": { "asset": { "symbol": "USD", "type": "fiat", "name": "US Dollar" }, "total": "1015.00", "subtotal": "1000.00", "fee": "10.00", "deposit_fee": "5.00", "affiliate_fee": "2.50", "withdrawal_fee": null }, "to": { "asset": { "symbol": "BTC", "type": "crypto", "name": "Bitcoin" }, "total": "0.01587302", "subtotal": "0.01587302", "fee": null, "deposit_fee": null, "affiliate_fee": null, "withdrawal_fee": "0.00010000" }, "unit_price": { "asset": { "symbol": "BTC", "type": "crypto", "name": "Bitcoin" }, "denomination_asset": { "symbol": "USD", "type": "fiat", "name": "US Dollar" }, "unit_price": "63000.00" } } } ``` **Affiliate fee note:** `affiliate_fee_bps` increases the fee charged to the user. In responses, `affiliate_fee` represents your portion of the returned `fee` amount, so do not add it again in your UI. ## Step 3 — Check transaction limits (optional) ``` GET /v1/ramp/limits ``` Returns the minimum and maximum transaction amounts for a given configuration. Call this to validate user input before generating a checkout URL or to display limit guidance in your UI. Uses the same required parameters as the prospective quote endpoint. ## Step 4 — Generate a checkout URL Once you have confirmed options and the user is ready to proceed, generate a hosted checkout URL. Redirect your user to this URL — Payward handles the payment form, KYC checks, and crypto delivery. ``` GET /v1/ramp/checkout ``` Required parameters: | Parameter | Description | | ------------- | -------------------------------------- | | `from_symbol` | Fiat currency ticker (e.g., `USD`) | | `from_method` | Payment method | | `from_amount` | Amount to charge | | `to_symbol` | Cryptocurrency asset ticker to deliver | | `to_method` | Withdrawal method | | `country` | User's country code | Useful optional parameters: | Parameter | Description | | ------------------------- | ------------------------------------------------------- | | `from_type` | Asset classification for the funding side | | `to_type` | Asset classification for the destination side | | `to_address` | Pre-fill the destination wallet address | | `to_network` | Specify the withdrawal network | | `to_memo` | Memo/tag for assets that require it (e.g., XRP) | | `redirect_url` | Where to send the user after a successful purchase | | `failure_url` | Where to send the user after a failed purchase | | `affiliate_fee_bps` | Your affiliate fee in basis points | | `external_user_id` | Your internal user ID (stored for reconciliation) | | `external_partner_id` | Your partner identifier for multi-tenant reconciliation | | `external_transaction_id` | Your internal transaction ID | | `external_metadata` | Arbitrary metadata (max 1000 chars) | The response contains the checkout URL and an echo of the submitted parameters for client-side confirmation, grouped into `from` and `to` side objects: ```json theme={null} { "data": { "checkout_url": "https://ramp.example.com/checkout/abc123def456", "request_data": { "from": { "symbol": "USD", "type": "fiat", "method": "credit_card", "amount": "1000.00" }, "to": { "symbol": "BTC", "type": "crypto", "method": "Bitcoin", "network": "Bitcoin", "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "memo": null }, "affiliate_fee_bps": "250", "country": "GB", "subdivision": null, "redirect_url": "https://example.com/checkout/success", "failure_url": "https://example.com/checkout/failure", "external_user_id": "user_01J0M7C0Z9F8YX3GQH8E", "external_partner_id": null, "external_transaction_id": "INV-2026-04-0042", "external_metadata": "{\"order_id\":\"order_12345\"}" } } } ``` Redirect the user to `checkout_url`. The hosted page collects card details, performs any required identity verification, and delivers the crypto. ## Receiving transaction updates Payward pushes real-time transaction status updates to a webhook endpoint **you implement**. Register your endpoint via the [Webhooks API](/api-reference/webhooks/register-webhook), then handle POST callbacks at your URL. Each callback payload includes the transaction status and, when complete, the delivered amount and destination address. The `external_transaction_id` and `external_user_id` you passed at checkout are echoed back, making reconciliation straightforward. ## API reference | Endpoint | Description | | --------------------------------- | --------------------------------------------------- | | `GET /v1/ramp/countries` | List supported countries and subdivisions | | `GET /v1/ramp/fiat-currencies` | List supported fiat currencies | | `GET /v1/ramp/payment-methods` | List supported payment methods | | `GET /v1/ramp/buy/crypto` | List cryptocurrency assets available for purchase | | `GET /v1/ramp/limits` | Get min/max transaction limits | | `GET /v1/ramp/quotes/prospective` | Preview from/to amounts without reserving liquidity | | `GET /v1/ramp/checkout` | Generate a hosted checkout URL | | `POST {your_webhook_url}` | Receive real-time transaction status updates | # Testing conversions Source: https://docs.services.payward.com/tabs/developer-documentation/payments/testing-conversions Exercise a conversion rule end to end in the sandbox by simulating an inbound deposit — no real bank transfer or on-chain send required. When you build on the [Conversion rules API](/tabs/developer-documentation/payments/conversion-rules), a rule only fires when a matching deposit lands on its provisioned inbound endpoint. In the sandbox that means you would otherwise have to send a real bank transfer or an on-chain transaction to see a conversion transaction appear. The **simulate deposit** endpoint removes that step: it injects a test deposit against an existing rule so you can verify your integration — rule creation, transaction polling, and your own reconciliation logic — without moving any real funds. **Sandbox only.** Simulated deposits are a testing aid for the sandbox environment. They do not move funds and are not available in production. Use them to validate your integration's wiring, not to test real settlement. **Crypto testnets — real on-chain flow.** For supported crypto networks, the sandbox can be backed by network testnets, so you can make **real** testnet deposits and withdrawals against a conversion rule and exercise the actual on-chain legs end to end — rather than simulating them. Which networks and testnets are enabled for your sandbox depends on your setup. Contact your Payward integration engineer for the current list and the testnet faucet/funding details. ## How it works You simulate a deposit against a conversion rule you have already created. PWS resolves the rule's inbound routing internally and submits a test deposit for the amount you specify — exactly as if a matching credit had arrived on the rule's `from` endpoint. The rule fires and produces a new **conversion transaction**, which you then monitor through the normal transactions endpoint. The end-to-end flow is three steps: 1. **Create a conversion rule** — the standing `from` → `to` configuration you want to test. 2. **Simulate a deposit** against that rule. 3. **Poll the rule's transactions** to watch the conversion progress to `completed`. ## Step 1 — Create a conversion rule Create the rule you want to exercise. This is the standard create call described in [Conversion rules](/tabs/developer-documentation/payments/conversion-rules) — here, a EUR on-ramp settling USDC to a Polygon wallet: ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "name": "EUR autoramp to Polygon vault", "from": { "symbol": "EUR", "type": "bank", "bank_account": { "via": "sepa" } }, "to": { "symbol": "USDC", "type": "wallet", "wallet": { "via": "polygon", "address": "0x1234567890abcdef1234567890abcdef12345678" } } }' ``` The full rule is returned (`from`, `to`, and timestamps included — see [Conversion rules](/tabs/developer-documentation/payments/conversion-rules#creating-a-rule--fiat-on-ramp-eur--usdc-on-polygon)). Note the `id` — you need it as the `conversion_id` in the next step: ```json theme={null} { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "EUR autoramp to Polygon vault", "status": "active" } } ``` **Wait for `active`.** Simulate a deposit only once the rule is `active`. If the rule is still `provisioning`, its inbound endpoint is not ready — fetch it with `GET /v1/accounts/{account_id}/conversions/{conversion_id}` until the status is `active`. ## Step 2 — Simulate a deposit Submit a test deposit against the rule with `POST /v1/accounts/{account_id}/conversions/{conversion_id}/testing/deposit`. The `amount` is the value to simulate as received on the rule's inbound side, in the source asset (EUR, for the rule above): ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions/550e8400-e29b-41d4-a716-446655440000/testing/deposit" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "amount": "100.00", "name": "Jane Doe" }' ``` | Field | Required | Description | | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `amount` | Yes | Positive decimal string to simulate as received on the rule's inbound, in the `from` asset. No surrounding whitespace. | | `name` | No | **Fiat only.** Simulates the name on the sending account as reported by the bank provider. Use it to exercise your internal risk checks — for example, name-match logic between the remitter and the account holder. Has no effect on crypto inbounds. | The response confirms only that PWS accepted the simulated deposit for processing — not that the conversion has completed: ```json theme={null} { "data": { "simulated": true } } ``` A `simulated: true` response means the test deposit was submitted upstream and the rule will fire. The resulting conversion transaction settles asynchronously; observe its progress in step 3. **`amount` is a string.** Send the amount as a JSON string (`"100.00"`), not a number, and trim any surrounding whitespace. A non-positive or whitespace-padded value is rejected with `400`. ## Step 3 — Watch the conversion transaction The simulated deposit produces a conversion transaction just like a real deposit would. Poll the rule's transactions to watch it move from `pending_deposit` through to `completed`: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/conversions/550e8400-e29b-41d4-a716-446655440000/transactions" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Sign: $PWS_API_SIGN" ``` ```json theme={null} { "data": [ { "id": "conv_smoke_001", "status": "completed", "conversion_rule_id": "550e8400-e29b-41d4-a716-446655440000", "from": { "asset_symbol": "EUR", "asset_amount": "100.00", "source_type": "bank", "source_via": "sepa" }, "to": { "asset_symbol": "USDC", "asset_amount": "108.42" }, "chain_references": { "withdraw_txid": "withdraw-usdc-001" }, "created_at": "2026-04-01T12:00:00Z" } ] } ``` The transaction moves through the same lifecycle as a production conversion (`pending_deposit` → `converting` → `settling` → `completed`). See [Monitoring conversions](/tabs/developer-documentation/payments/conversion-rules#monitoring-conversions) for the full state table. **Webhooks.** A simulated deposit emits the same conversion webhook events as a real one (`conversion.deposit_completed`, `conversion.deposit_held`, `conversion.withdrawal_completed`, and the rest), so you can use it to test your webhook handler end to end. Register your endpoint with the [Register Webhook](/api-reference/webhooks/register-webhook) API. You can also follow the simulated conversion by polling the transactions endpoint, exactly as in production. ## API reference | Endpoint | Description | | ---------------------------------------------------------------------------- | ---------------------------------------------------- | | `POST /v1/accounts/{account_id}/conversions/{conversion_id}/testing/deposit` | Simulate an inbound deposit against a rule (sandbox) | | `GET /v1/accounts/{account_id}/conversions/{conversion_id}/transactions` | List the transactions produced by a rule | # Transfers Source: https://docs.services.payward.com/tabs/developer-documentation/payments/transfers Move assets between accounts in your Payward Services program and reconcile the result through Portfolio. ## Overview The Transfers API moves an asset from one PWS account to another account in the authenticated caller's program. The source and destination must be distinct accounts. The accounts may belong to the same user or to different users, but both must be in the authenticated caller's program. Only account-to-account transfers are supported. Email, username-tag, and other recipient types are not supported. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)) * A source account with a sufficient available balance * A destination account in the same program ## Create a transfer Send `POST /v1/transfers` with the source account, destination account, asset symbol, and decimal amount. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/transfers" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -d '{ "from": "WVSD33HRMGSZUBM7", "to": "WNPY3PJZYD4L9E7B", "asset": "USD", "amount": "25.50" }' ``` ### Make retries idempotent Generate one UUIDv4 for each intended transfer. Reuse that same key only when retrying the identical request. * The same key and request prevent a second transfer execution. The retry returns the transfer's current recorded outcome. * The same key with different, otherwise valid transfer parameters returns `409 Conflict` with code `idempotency_conflict`. * A keyed retry does not replay the original HTTP status and body. The response does not include `Idempotent-Replayed`. * A request rejected before it is recorded may leave the key available for another request. * A recorded key remains reserved beyond 24 hours. Never reuse it for a new transfer. If you omit `Idempotency-Key`, transfer creation is not idempotent. Do not automatically retry a timeout or another unknown outcome. Reconcile the source account through Portfolio or contact Payward Services support first. ### Response A successful request returns HTTP `201`: ```json theme={null} { "data": { "transfer_id": "TRYCMN7-WXKJM-F47YIM", "status": "pending" } } ``` | Status | Description | | ---------- | ------------------------------------------------------- | | `pending` | The transfer is still being processed. | | `complete` | The transfer creation operation completed. | | `failed` | The transfer creation operation could not be completed. | `transfer_id` is an opaque originating reference. It is distinct from the Portfolio transaction ID. ## Reconcile through Portfolio Transfer creation and Portfolio visibility are asynchronous. A create response with `status: complete` does not guarantee that the Portfolio transaction is visible yet. Poll `GET /v1/accounts/{account_id}/portfolio/transactions` using: * the request's `from` account as `account_id` * `types=transfer` * a time range that includes the create request * no `statuses` filter, or a filter that includes `in_progress` ```bash theme={null} curl --get "https://api.services.payward.com/v1/accounts/WVSD33HRMGSZUBM7/portfolio/transactions" \ --data-urlencode "types=transfer" \ --data-urlencode "start_timestamp=2026-07-23T10:00:00Z" \ --data-urlencode "end_timestamp=2026-07-23T11:00:00Z" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" ``` Portfolio transactions are account-relative. The `from` account response contains `spend`, while the `to` account response contains `receive`. The example below queries the `from` account, so it shows only `spend`. When the transaction appears, its `reference` links it to the create response: ```json theme={null} { "data": [ { "id": "TXYCMN8-ABCD2-EFGH3I", "timestamp": "2026-07-23T10:15:30Z", "type": "transfer", "status": "in_progress", "reference": { "kind": "transfer", "id": "TRYCMN7-WXKJM-F47YIM" }, "spend": { "sub_total": { "symbol": "USD", "name": "US Dollar", "type": "fiat", "amount": "25.50", "amount_in_quote": "25.50" }, "total": { "symbol": "USD", "name": "US Dollar", "type": "fiat", "amount": "25.50", "amount_in_quote": "25.50" }, "timestamp": "2026-07-23T10:15:30Z" } } ], "quote_symbol": "USD", "quote_type": "fiat" } ``` Use the reference for deterministic later lookups: ```text theme={null} references[0][kind]=transfer references[0][id]=TRYCMN7-WXKJM-F47YIM ``` Reference entries use OR semantics with each other. They combine with `types` and other filters using AND semantics. Keep these identifiers distinct: | Identifier | Meaning | | ------------------------------ | ------------------------------------------- | | Portfolio `id` (`TX...`) | The Portfolio transaction identifier. | | `reference.id` with `funding` | The originating Funding deposit identifier. | | `reference.id` with `transfer` | The `transfer_id` returned by create. | ## Error handling | HTTP status | Cause | | --------------------------- | ----------------------------------------------------------- | | `400 Bad Request` | Invalid request, unsupported asset, or insufficient balance | | `401 Unauthorized` | Missing or invalid API credentials | | `403 Forbidden` | The caller cannot access an account or operation | | `409 Conflict` | Idempotency key conflict or stale conversion rate | | `429 Too Many Requests` | Rate limit exceeded | | `500 Internal Server Error` | Unexpected service failure | | `503 Service Unavailable` | A required service is temporarily unavailable | | `504 Gateway Timeout` | A required service did not respond in time | # On-chain swaps Source: https://docs.services.payward.com/tabs/developer-documentation/trade/onchain-quotes The on-chain swaps API lets you create a wallet-funded xStocks quote, submit the returned payload on-chain, and poll until the quote reaches a terminal state. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)). * 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 Create a wallet-funded xStocks on-chain quote and receive the on-chain payload. `POST /v1/accounts/{account_id}/onchain-quotes` Call the Payward on-chain proxy contract with the returned payload before `expires_at`. `executeSwap(...)` Wait for Payward Services to observe settlement, or subscribe to webhooks. `GET /v1/accounts/{account_id}/onchain-quotes/{quote_id}` ## Quote lifecycle | Status | Description | | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `pending` | Quote is being prepared. Transitions to `offered` once the signed payload is ready, or to `failed` if preparation fails. | | `offered` | Quote created and locked. Valid until `expires_at`. | | `executing` | The on-chain transaction was observed and is awaiting confirmation. | | `executed` | Terminal. The on-chain quote completed. | | `expired` | Terminal. `expires_at` elapsed before settlement. | | `failed` | Terminal. 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. | Field | Required | Description | | ---------------------------- | -------- | -------------------------------------------------------------------------------- | | `from.wallet` | yes | `{ address, network }`. Must be an approved same-owner withdrawal address. | | `from.symbol` | yes | Source asset symbol. Use `USDC` when buying xStocks, or the xStock when selling. | | `from.type` | yes | `stablecoin` or `xstock`. | | `to.symbol` | yes | Destination asset symbol. | | `to.type` | yes | `stablecoin` or `xstock`. | | `from.amount` or `to.amount` | one of | Set 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](/tabs/developer-documentation/get-started/authentication) for the full algorithm. ```python Python theme={null} 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 ``` ```javascript Javascript theme={null} import crypto from 'crypto'; const API_KEY = process.env.PWS_API_KEY; const API_SECRET = process.env.PWS_API_SECRET; const BASE_URL = 'https://api.services.payward.com'; function getPaywardSignature(urlpath, data, secret, nonce, params = null) { const encoded = data === null ? String(nonce) : String(nonce) + JSON.stringify(data); let signPath = urlpath; if (params) { signPath += '?' + new URLSearchParams(params).toString(); } const sha256Hash = crypto.createHash('sha256').update(encoded).digest(); const message = Buffer.concat([Buffer.from(signPath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } function pwsHeaders(signature, nonce, { idempotent = false } = {}) { const headers = { 'API-Key': API_KEY, 'API-Sign': signature, 'API-Nonce': String(nonce), 'Content-Type': 'application/json', }; if (idempotent) { headers['Idempotency-Key'] = crypto.randomUUID(); } 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 Typescript theme={null} 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 status | Code | Cause | Remediation | | ------------------------- | ------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------- | | `400 Bad Request` | validation error | `from.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 Request` | validation error | Both or neither of `from.amount` / `to.amount` were set. | Set exactly one side of the trade. | | `400 Bad Request` | validation error | `from`/`to` pair is not an xStock/USDC trade. | Restrict trades to xStock ↔ stablecoin pairs. | | `403 Forbidden` | forbidden | The user is not eligible for on-chain quote trading. | Confirm the user's eligibility with your account manager. | | `410 Gone` | `quote_expired` | `expires_at` elapsed before settlement. | Request a fresh quote and broadcast more quickly. | | `503 Service Unavailable` | `insufficient_liquidity` | No 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 | Endpoint | Method | Description | | ----------------------------------------------------- | ------ | ---------------------------------------------------------------------------------- | | `/v1/accounts/{account_id}/onchain-quotes` | POST | Create a wallet-funded on-chain quote. Returns the quote with `execution_details`. | | `/v1/accounts/{account_id}/onchain-quotes` | GET | List on-chain quotes for the authenticated user (paginated, newest first). | | `/v1/accounts/{account_id}/onchain-quotes/{quote_id}` | GET | Read on-chain quote status and amounts. Does not return a new execution payload. | # Price trigger swaps Source: https://docs.services.payward.com/tabs/developer-documentation/trade/price-trigger-swaps Price trigger swaps are price-triggered trades that execute automatically when an asset reaches a target price. Use them to implement strategies like "Buy the Dip" (buy when price drops to a target), "Join the Rally" (buy when price rises to ride momentum), "Take the Profit" (sell when price rises to a target), and "Stop the Loss" (sell when price drops to limit losses) for your users. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)). * 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 Submit the swap with its trigger condition. `POST /v1/accounts/{account_id}/price-trigger-swaps` Track status, list active swaps, or cancel one that hasn't fired yet. * `GET /v1/accounts/{account_id}/price-trigger-swaps` * `GET /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}` * `POST /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}/cancel` ## Trigger conditions The `when` block specifies the price to monitor and the threshold that fires the trade. Set the `base` and `quote` `AssetRef` pair, then exactly one of `drops_to` or `rises_to`: | Field | Description | | ---------- | --------------------------------------------------------------------- | | `drops_to` | Fire when the price falls to or below this value (e.g. "buy on dip") | | `rises_to` | Fire when the price rises to or above this value (e.g. "take profit") | The threshold is a `base`/`quote` rate as a decimal string. For example, with `base.symbol` = `BTC` and `quote.symbol` = `USD`, `drops_to: "50000.00"` means "fire when 1 BTC trades at 50,000 USD or below". **Examples:** * **Buy the Dip:** "Buy BTC when BTC/USD drops to 50,000" — set `base.symbol` to `BTC`, `quote.symbol` to `USD`, and `drops_to` to `"50000.00"`. Pair with a `trade` whose `from` is `USD` and `to` is `BTC`. * **Stop the Loss:** "Sell BTC when BTC/USD drops to 50,000" — same `when` block, but with `trade.from` set to `BTC` and `trade.to` set to `USD`. * **Join the Rally:** "Buy BTC when BTC/USD rises to 80,000" — same pair, replace `drops_to` with `rises_to: "80000.00"`. Trade goes USD → BTC. * **Take the Profit:** "Sell BTC when BTC/USD rises to 80,000" — same `when` block as Join the Rally, but with `trade.from` set to `BTC` and `trade.to` set to `USD`. ## Execution behavior Price trigger swaps are not guaranteed to execute at exactly the threshold price. The threshold is a **trigger**, not a limit price. The execution price can move beyond the threshold, but only ever in the direction that fired the trigger — it never crosses back to the other side of the threshold: * **Buy the Dip / Stop the Loss (`drops_to`):** The swap triggers when the market price drops to or below `drops_to`. The execution price may be lower than the threshold, but never higher. * **Join the Rally / Take the Profit (`rises_to`):** The swap triggers when the market price rises to or above `rises_to`. The execution price may be higher than the threshold, but never lower. For swaps where the execution price is higher than the market price and the user is spending the client's reserve fiat currency, an error will be returned at creation. Because the execution price may be higher than the threshold, the final spend amount is unbounded upward — this prevents a situation where a user creates a swap that could spend more fiat than the client has approved. ## 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](/tabs/developer-documentation/get-started/authentication) for the full algorithm. ```python Python theme={null} 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 ``` ```javascript Javascript theme={null} import crypto from 'crypto'; const API_KEY = process.env.PWS_API_KEY; const API_SECRET = process.env.PWS_API_SECRET; const BASE_URL = 'https://api.services.payward.com'; function getPaywardSignature(urlpath, data, secret, nonce, params = null) { const encoded = data === null ? String(nonce) : String(nonce) + JSON.stringify(data); let signPath = urlpath; if (params) { signPath += '?' + new URLSearchParams(params).toString(); } const sha256Hash = crypto.createHash('sha256').update(encoded).digest(); const message = Buffer.concat([Buffer.from(signPath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } function pwsHeaders(signature, nonce, { idempotent = false } = {}) { const headers = { 'API-Key': API_KEY, 'API-Sign': signature, 'API-Nonce': String(nonce), 'Content-Type': 'application/json', }; if (idempotent) { headers['Idempotency-Key'] = crypto.randomUUID(); } return headers; } ``` ## Step 1: create a price trigger swap Submit the swap with `trade`, `when`, and an optional `client_order_id`. The `client_order_id` is a client-supplied identifier (1–64 printable ASCII characters) that uniquely identifies the swap — it's echoed back on reads and webhook events. It must be unique per account: reusing a value already associated with another price trigger swap on the same account returns `409 Conflict` with `code: price_trigger_swap_client_order_id_duplicate`. The response returns only the new swap's `id`; fetch `GET /v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}` to read the full resource. Set the `amount` on **exactly one** of `trade.from` or `trade.to` to indicate which side of the trade is fixed. Setting both or neither returns `400 Bad Request`. The `trade.fee` object sets the partner's fee on the trade that fires. Provide `fee.bps` (basis points, 1 bps = 0.01%), `fee.absolute` (a flat amount denominated in the trade's quote currency, sent as a decimal string), or both — when both are set they combine. Omit `fee` to charge no partner fee. ```python Python theme={null} def create_price_trigger_swap(account_id): endpoint = f"/v1/accounts/{account_id}/price-trigger-swaps" nonce = time.time_ns() body = { "client_order_id": "buy-dip-btc-001", "trade": { "from": {"symbol": "USD", "type": "fiat", "amount": "100.00"}, "to": {"symbol": "BTC", "type": "crypto"}, "fee": {"bps": 50}, }, "when": { "base": {"symbol": "BTC", "type": "crypto"}, "quote": {"symbol": "USD", "type": "fiat"}, "drops_to": "50000.00", }, } signature = get_payward_signature(endpoint, body, API_SECRET, nonce) response = requests.post( BASE_URL + endpoint, headers=pws_headers(signature, nonce, idempotent=True), json=body, ) return response.json() account_id = "NL00KRAK0123456789" created = create_price_trigger_swap(account_id) swap_id = created["data"]["id"] print(f"Swap created: {swap_id}") ``` ```javascript Javascript theme={null} async function createPriceTriggerSwap(accountId) { const endpoint = `/v1/accounts/${accountId}/price-trigger-swaps`; const nonce = process.hrtime.bigint().toString(); const body = { client_order_id: 'buy-dip-btc-001', trade: { from: { symbol: 'USD', type: 'fiat', amount: '100.00' }, to: { symbol: 'BTC', type: 'crypto' }, fee: { bps: 50 }, }, when: { base: { symbol: 'BTC', type: 'crypto' }, quote: { symbol: 'USD', type: 'fiat' }, drops_to: '50000.00', }, }; const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'POST', headers: pwsHeaders(signature, nonce, { idempotent: true }), body: JSON.stringify(body), }); return response.json(); } const accountId = 'NL00KRAK0123456789'; const created = await createPriceTriggerSwap(accountId); const swapId = created.data.id; console.log('Swap created:', swapId); ``` ### Response example ```json theme={null} { "data": { "id": "swap_01J0M7C0Z9F8YX3GQH8E" } } ``` The response also carries a `Location` header pointing at the new resource (e.g. `/v1/accounts/NL00KRAK0123456789/price-trigger-swaps/swap_01J0M7C0Z9F8YX3GQH8E`). ## Step 2: monitor swaps ### Get a single swap ```python Python theme={null} def get_price_trigger_swap(account_id, swap_id): endpoint = f"/v1/accounts/{account_id}/price-trigger-swaps/{swap_id}" 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() swap = get_price_trigger_swap(account_id, swap_id) print(f"Status: {swap['data']['status']}") ``` ```javascript Javascript theme={null} async function getPriceTriggerSwap(accountId, swapId) { const endpoint = `/v1/accounts/${accountId}/price-trigger-swaps/${swapId}`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const swap = await getPriceTriggerSwap(accountId, swapId); console.log('Status:', swap.data.status); ``` #### Response example ```json theme={null} { "data": { "id": "swap_01J0M7C0Z9F8YX3GQH8E", "client_order_id": "buy-dip-btc-001", "trade": { "from": { "symbol": "USD", "type": "fiat", "amount": "100.00" }, "to": { "symbol": "BTC", "type": "crypto" } }, "when": { "base": { "symbol": "BTC", "type": "crypto" }, "quote": { "symbol": "USD", "type": "fiat" }, "drops_to": "50000.00" }, "status": "active" } } ``` When `status` is `completed`, the response also includes `trade.fees` and `trade.rate` reflecting what was realised at execution time. ### List swaps Filter by status using the `statuses` query parameter (repeat for multiple values, or omit to include any status). Use `page_token` and `page_size` (default 20, max 25) for pagination — `next_page_token` is omitted on the final page. Pass the previous response's `next_page_token` as `page_token`; for this endpoint that value is a price-triggered swap id (for example `ALCCIA-CUT6P-XCW6K7`). ```python Python theme={null} def list_price_trigger_swaps(account_id, statuses=None, page_token=None, page_size=20): endpoint = f"/v1/accounts/{account_id}/price-trigger-swaps" nonce = time.time_ns() params = [] if statuses: params.extend(("statuses", s) for s in statuses) if page_token: params.append(("page_token", page_token)) params.append(("page_size", page_size)) signature = get_payward_signature(endpoint, None, API_SECRET, nonce, params) response = requests.get( BASE_URL + endpoint, headers=pws_headers(signature, nonce), params=params, ) return response.json() page = list_price_trigger_swaps(account_id, statuses=["active"]) for s in page["data"]: print(f"{s.get('client_order_id', s['id'])} — {s['status']}") ``` ```javascript Javascript theme={null} async function listPriceTriggerSwaps(accountId, { statuses = [], pageToken, pageSize = 20 } = {}) { const endpoint = `/v1/accounts/${accountId}/price-trigger-swaps`; const nonce = process.hrtime.bigint().toString(); const search = new URLSearchParams(); for (const s of statuses) search.append('statuses', s); if (pageToken) search.append('page_token', pageToken); search.append('page_size', String(pageSize)); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce, Object.fromEntries(search.entries())); const response = await fetch(`${BASE_URL}${endpoint}?${search}`, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } const page = await listPriceTriggerSwaps(accountId, { statuses: ['active'] }); for (const s of page.data) { console.log(`${s.client_order_id ?? s.id} — ${s.status}`); } ``` ### Swap statuses | Status | Description | | ----------- | -------------------------------------------------------------------------- | | `active` | Swap is monitoring the market, waiting for the trigger condition to be met | | `completed` | Trigger condition was met and the trade executed | | `cancelled` | Cancelled at the user's request before the trigger fired | | `failed` | Terminated by Payward before completing, for a non-user reason | A `cancelled` swap is always user-initiated and carries no extra reason. When `status` is `failed`, the response includes a `failure_reason` field explaining why it was terminated. The value is one of: | `failure_reason` | Description | | ------------------------ | ------------------------------------------------------------ | | `user_locked` | The user's account was locked | | `expired_payment_method` | The funding payment method expired | | `asset_unavailable` | One of the assets in the pair became unavailable for trading | | `pair_unavailable` | The trading pair became unavailable | | `retries_exhausted` | The swap exhausted its execution retries | | `funding_method_deleted` | The funding method backing the swap was deleted | | `other` | Terminated for a reason not listed above | ## Step 3: cancel a swap Cancel an `active` swap that hasn't triggered yet. The body is empty. Swaps in `completed`, `cancelled`, or `failed` status are terminal and return `409 Conflict` with `code: price_trigger_swap_not_cancellable`. ```python Python theme={null} def cancel_price_trigger_swap(account_id, swap_id): endpoint = f"/v1/accounts/{account_id}/price-trigger-swaps/{swap_id}/cancel" nonce = time.time_ns() body = {} signature = get_payward_signature(endpoint, body, API_SECRET, nonce) response = requests.post( BASE_URL + endpoint, headers=pws_headers(signature, nonce, idempotent=True), json=body, ) response.raise_for_status() cancel_price_trigger_swap(account_id, swap_id) swap = get_price_trigger_swap(account_id, swap_id) print(f"Status: {swap['data']['status']}") # cancelled ``` ```javascript Javascript theme={null} async function cancelPriceTriggerSwap(accountId, swapId) { const endpoint = `/v1/accounts/${accountId}/price-trigger-swaps/${swapId}/cancel`; const nonce = process.hrtime.bigint().toString(); const body = {}; const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'POST', headers: pwsHeaders(signature, nonce, { idempotent: true }), body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`Cancel failed: ${response.status}`); } } await cancelPriceTriggerSwap(accountId, swapId); const after = await getPriceTriggerSwap(accountId, swapId); console.log('Status:', after.data.status); // cancelled ``` ## Webhook events | Event type | Delivered when | | ------------------------------------- | ------------------------------------------------------------------------------- | | `price_trigger_swap.executed` | Trigger fired and the resulting trade settled successfully | | `price_trigger_swap.execution_failed` | Trigger fired but the trade failed to execute (may retry, may move to `failed`) | | `price_trigger_swap.cancelled` | Swap moved to `cancelled` status | | `transaction.price_trigger_swap` | The resulting Portfolio ledger transaction was created or updated | ## Portfolio transactions Successfully executed price trigger swaps appear in the List Portfolio Transactions endpoint as `price_trigger_swap`. Failed execution attempts do not appear because they have no ledger movements. Track them with the `price_trigger_swap.execution_failed` webhook or the price trigger swap status. ```http theme={null} GET /v1/accounts/{account_id}/portfolio/transactions?types=price_trigger_swap ``` ## API reference | Endpoint | Method | Description | | ------------------------------------------------------------------------------ | ------ | ----------------------------------------------- | | `/v1/accounts/{account_id}/price-trigger-swaps` | POST | Create a price trigger swap | | `/v1/accounts/{account_id}/price-trigger-swaps` | GET | List price trigger swaps (filterable by status) | | `/v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}` | GET | Get a single price trigger swap | | `/v1/accounts/{account_id}/price-trigger-swaps/{price_trigger_swap_id}/cancel` | POST | Cancel an active price trigger swap | # Swaps Source: https://docs.services.payward.com/tabs/developer-documentation/trade/swap The Swaps API offers institutional FX-style trading. Lock a price for ~2 minutes, execute against the locked quote, then poll until the trade reaches a terminal state. ## Prerequisites * Payward Services API credentials (see the [Authentication guide](/tabs/developer-documentation/get-started/authentication)). * 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 Confirm the amount you plan to fix falls within current min/max bounds. `GET /v1/accounts/{account_id}/quotes/limits` Lock a price for \~2 minutes. `POST /v1/accounts/{account_id}/quotes` Commit the locked price before it expires. `POST /v1/accounts/{account_id}/quotes/{quote_id}/execute` Wait for the quote to settle, or subscribe to webhooks. `GET /v1/accounts/{account_id}/quotes/{quote_id}` ## Quote lifecycle | Status | Description | | ----------- | -------------------------------------------------------------------------------------------- | | `offered` | Quote created and locked. May still expire if the market moves too far before you execute. | | `executing` | Execution accepted. The trade is being processed asynchronously. | | `executed` | Terminal. The trade settled. | | `expired` | Terminal. The TTL elapsed, or the market moved too far for the locked price to remain valid. | | `failed` | Terminal. The trade failed to execute. | A quote is valid for approximately 120 seconds (2 minutes) after creation. Calling execute on a quote whose `expires_at` has passed returns `410 Gone`. ## Specifying the trade A quote needs `from`, `to`, and `fee` fields. Set the `amount` on **exactly one** of `from` or `to` to indicate which side of the trade is fixed. The server calculates the other side. Setting both or neither returns `400 Bad Request`. | Scenario | `from.amount` | `to.amount` | | ------------------------------------------- | ------------- | ----------- | | "Sell 0.5 BTC for whatever USD that yields" | `"0.5"` | omit | | "Buy whatever BTC \$5,000 yields" | omit | `"5000"` | Each side carries a `type` that classifies the asset: | `type` | Examples | | ------------ | --------------------- | | `fiat` | `USD`, `EUR`, `GBP` | | `crypto` | `BTC`, `ETH`, `SOL` | | `stablecoin` | `USDC`, `USDT`, `DAI` | | `xstock` | `TSLAx`, `AAPLx` | The `fee` object sets the partner's fee applied on top of the execution price. Provide it as basis points, a flat amount, or both: * `fee.bps` — fee in basis points (1 bps = 0.01%, so 50 bps is 0.5%). Defaults to `0` when omitted. * `fee.absolute` — a flat fee amount denominated in the quote currency, sent as a decimal string (e.g. `"10.00"`). When both are set they combine. Omit `fee` entirely to charge no partner fee. An `absolute` value that is too small to apply or too large for the trade size is rejected with `400 Bad Request` on `fee.absolute`. ## Check quote limits Before creating a quote, you can fetch the effective amount limits for a pair on the selected account: ``` GET /v1/accounts/{account_id}/quotes/limits ``` Required query parameters: | Parameter | Description | | ------------- | -------------------------------------------------------------------------------------------------- | | `from_symbol` | Source asset ticker (e.g. `USD`) | | `from_type` | Source asset classification (`fiat`, `crypto`, `stablecoin`, or `xstock`) | | `to_symbol` | Destination asset ticker (e.g. `ETH`) | | `to_type` | Destination asset classification | | `amount_side` | Side whose amount you will fix when creating the quote (`from` or `to`). Limits are in that asset. | Limits are a point-in-time estimate and can change before you create a quote. Clamp create-quote amounts to `[minimum_swap, maximum]`. When present, `limit_reasons` explains which constraints contributed to `maximum`. Dust-only floors are not exposed on this endpoint; a dust-sweep-specific minimum may be added later. ### Response example ```json theme={null} { "data": { "asset": { "symbol": "USD", "type": "fiat" }, "minimum_swap": "1", "maximum": "150000", "precision": 2, "limit_reasons": ["liquidity"] } } ``` | Field | Description | | --------------- | --------------------------------------------------------------------------------- | | `minimum_swap` | Smallest amount valid for create quote in the `amount_side` asset | | `maximum` | Largest amount currently available in the `amount_side` asset | | `precision` | Max decimal places for ordinary tradable amounts | | `limit_reasons` | Optional constraints that shaped `maximum` (`user_balance`, `liquidity`, `other`) | ## 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](/tabs/developer-documentation/get-started/authentication) for the full algorithm. ```python Python theme={null} 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 ``` ```javascript Javascript theme={null} import crypto from 'crypto'; const API_KEY = process.env.PWS_API_KEY; const API_SECRET = process.env.PWS_API_SECRET; const BASE_URL = 'https://api.services.payward.com'; function getPaywardSignature(urlpath, data, secret, nonce, params = null) { const encoded = data === null ? String(nonce) : String(nonce) + JSON.stringify(data); let signPath = urlpath; if (params) { signPath += '?' + new URLSearchParams(params).toString(); } const sha256Hash = crypto.createHash('sha256').update(encoded).digest(); const message = Buffer.concat([Buffer.from(signPath), sha256Hash]); const secretBuffer = Buffer.from(secret, 'base64'); const hmac = crypto.createHmac('sha512', secretBuffer); hmac.update(message); return hmac.digest('base64'); } function pwsHeaders(signature, nonce, { idempotent = false } = {}) { const headers = { 'API-Key': API_KEY, 'API-Sign': signature, 'API-Nonce': String(nonce), 'Content-Type': 'application/json', }; if (idempotent) { headers['Idempotency-Key'] = crypto.randomUUID(); } return headers; } ``` ## Step 1: request a quote Send a `POST` to `/v1/accounts/{account_id}/quotes` with the trade body. ```python Python theme={null} def create_swap_quote(account_id): endpoint = f"/v1/accounts/{account_id}/quotes" nonce = time.time_ns() body = { "from": {"symbol": "BTC", "type": "crypto", "amount": "0.5"}, "to": {"symbol": "USD", "type": "fiat"}, "fee": {"bps": 50}, } signature = get_payward_signature(endpoint, body, API_SECRET, nonce) response = requests.post( BASE_URL + endpoint, headers=pws_headers(signature, nonce), json=body, ) return response.json() account_id = "NL00KRAK0123456789" quote = create_swap_quote(account_id) print(f"Quote ID: {quote['data']['id']}") print(f"Status: {quote['data']['status']}") print(f"Expires at: {quote['data']['expires_at']}") print(f"You send: {quote['data']['from']['amount']} {quote['data']['from']['symbol']}") print(f"You receive: {quote['data']['to']['amount']} {quote['data']['to']['symbol']}") ``` ```javascript Javascript theme={null} async function createSwapQuote(accountId) { const endpoint = `/v1/accounts/${accountId}/quotes`; const nonce = process.hrtime.bigint().toString(); const body = { from: { symbol: 'BTC', type: 'crypto', amount: '0.5' }, to: { symbol: 'USD', type: 'fiat' }, fee: { bps: 50 }, }; const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'POST', headers: pwsHeaders(signature, nonce), body: JSON.stringify(body), }); return response.json(); } const accountId = 'NL00KRAK0123456789'; const quote = await createSwapQuote(accountId); console.log('Quote ID: ', quote.data.id); console.log('Status: ', quote.data.status); console.log('Expires at: ', quote.data.expires_at); ``` ### Response example ```json theme={null} { "data": { "id": "Q-BTC2USD-1", "status": "offered", "created_at": "2026-04-23T16:00:00Z", "expires_at": "2026-04-23T16:00:30Z", "from": { "symbol": "BTC", "type": "crypto", "amount": "0.5" }, "to": { "symbol": "USD", "type": "fiat", "amount": "31495.50" }, "fees": { "trade": { "symbol": "USD", "type": "fiat", "amount": "150.00" } }, "rate": { "base": { "symbol": "BTC", "type": "crypto" }, "quote": { "symbol": "USD", "type": "fiat" }, "price": "63291.00" } } } ``` Quotes expire after **120 seconds** (2 minutes). Execute the quote promptly or request a new one if it expires. The exact expiration time is returned in the `expires_at` field of the quote response. ## Step 2: execute the quote Send a `POST` to `/v1/accounts/{account_id}/quotes/{quote_id}/execute` with an empty body. The response confirms the quote moved to `executing`; settlement happens asynchronously. ```python Python theme={null} def execute_swap_quote(account_id, quote_id): endpoint = f"/v1/accounts/{account_id}/quotes/{quote_id}/execute" nonce = time.time_ns() body = {} signature = get_payward_signature(endpoint, body, API_SECRET, nonce) response = requests.post( BASE_URL + endpoint, headers=pws_headers(signature, nonce), json=body, ) return response.json() execution = execute_swap_quote(account_id, quote["data"]["id"]) print(f"Status: {execution['data']['status']}") ``` ```javascript Javascript theme={null} async function executeSwapQuote(accountId, quoteId) { const endpoint = `/v1/accounts/${accountId}/quotes/${quoteId}/execute`; const nonce = process.hrtime.bigint().toString(); const body = {}; const signature = getPaywardSignature(endpoint, body, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'POST', headers: pwsHeaders(signature, nonce), body: JSON.stringify(body), }); return response.json(); } const execution = await executeSwapQuote(accountId, quote.data.id); console.log('Status:', execution.data.status); ``` ### Response example ```json theme={null} { "data": { "id": "Q-BTC2USD-1", "status": "executing" } } ``` ## Step 3: poll for terminal status After executing, poll `GET /v1/accounts/{account_id}/quotes/{quote_id}` until `status` reaches a terminal value (`executed`, `expired`, or `failed`). ```python Python theme={null} def get_swap_quote(account_id, quote_id): endpoint = f"/v1/accounts/{account_id}/quotes/{quote_id}" 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_terminal_status(account_id, quote_id, max_attempts=60): terminal = {"executed", "expired", "failed"} for _ in range(max_attempts): quote = get_swap_quote(account_id, quote_id) status = quote["data"]["status"] print(f"Status: {status}") if status in terminal: return quote time.sleep(1) raise RuntimeError("Quote did not reach a terminal state in time") final = wait_for_terminal_status(account_id, quote["data"]["id"]) ``` ```javascript Javascript theme={null} async function getSwapQuote(accountId, quoteId) { const endpoint = `/v1/accounts/${accountId}/quotes/${quoteId}`; const nonce = process.hrtime.bigint().toString(); const signature = getPaywardSignature(endpoint, null, API_SECRET, nonce); const response = await fetch(BASE_URL + endpoint, { method: 'GET', headers: pwsHeaders(signature, nonce), }); return response.json(); } async function waitForTerminalStatus(accountId, quoteId, maxAttempts = 60) { const terminal = new Set(['executed', 'expired', 'failed']); for (let i = 0; i < maxAttempts; i++) { const quote = await getSwapQuote(accountId, quoteId); const { status } = quote.data; console.log('Status:', status); if (terminal.has(status)) { return quote; } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error('Quote did not reach a terminal state in time'); } const final = await waitForTerminalStatus(accountId, quote.data.id); ``` ### Response example ```json theme={null} { "data": { "id": "Q-BTC2USD-1", "status": "executed", "created_at": "2026-04-23T16:00:00Z", "expires_at": "2026-04-23T16:00:30Z", "from": { "symbol": "BTC", "type": "crypto", "amount": "0.5" }, "to": { "symbol": "USD", "type": "fiat", "amount": "31495.50" }, "fees": { "trade": { "symbol": "USD", "type": "fiat", "amount": "150.00" } }, "rate": { "base": { "symbol": "BTC", "type": "crypto" }, "quote": { "symbol": "USD", "type": "fiat" }, "price": "63291.00" } } } ``` Instead of polling, subscribe to the `quote.executed` and `quote.execution_failed` webhooks and use `GET` only for reconciliation. ## Error handling | HTTP status | Code | Cause | Remediation | | ----------------- | -------------------- | ------------------------------------------------------- | ------------------------------------------------- | | `400 Bad Request` | validation error | Both or neither of `from.amount` / `to.amount` were set | Set exactly one side of the trade | | `409 Conflict` | `insufficient_funds` | The account balance cannot cover the quote | Fund the account or request a smaller quote | | `410 Gone` | `quote_expired` | The quote was executed after its `expires_at` | Request a fresh quote and execute it more quickly | ### Best practices * **Capture `Request-Id` from response headers.** It speeds up support investigations. ## API reference | Endpoint | Method | Description | | ----------------------------------------------------- | ------ | -------------------------------------- | | `/v1/accounts/{account_id}/quotes/limits` | GET | Get effective quote amount limits | | `/v1/accounts/{account_id}/quotes` | POST | Create a swap quote (locks price \~2m) | | `/v1/accounts/{account_id}/quotes/{quote_id}/execute` | POST | Execute a locked quote | | `/v1/accounts/{account_id}/quotes/{quote_id}` | GET | Read the current state of a quote | # Hosted KYC Source: https://docs.services.payward.com/tabs/developer-documentation/verifications/hosted-kyc Verify users through Payward-managed web or embedded methods. Hosted KYC collects verification evidence through Payward-managed web or embedded methods. You create the user and request the methods currently available to the user. Hosted KYC is asynchronous. A hosted method starts or continues collection. It is not a verification decision. ## How it works Call [`POST /v1/users`][create-user] with the user's email and your external reference. Call [`POST /v1/users/{user_id}/verifications/hosted`][create-hosted-verification]. Use the returned `methods.web` or `methods.embedded` value before it expires. If the response contains `not_required`, do not start another verification. Use [webhooks][register-webhook], [Get Verification][get-verification], and [Get Verification Requirements][get-verification-requirements]. ## Create the user Create the account-owned user resource first: All requests use [PWS API authentication][authentication]. ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/users" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{ "email": "alex.morgan@example.com", "external_reference": "partner-customer-9001" }' ``` Store the returned user `id`. ## Create a hosted verification Call [Create Hosted Verification][create-hosted-verification] when the user is ready: ```bash theme={null} curl -X POST "https://api.services.payward.com/v1/users/$USER_ID/verifications/hosted" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" \ -H "Content-Type: application/json" \ -d '{}' ``` The response contains one or more available methods. `web` and `embedded` are independently optional, but `methods` always contains at least one of them: ```json theme={null} { "data": { "methods": { "web": { "url": "https://in.sumsub.com/websdk/pws-example", "expires_at": "2026-07-29T18:00:00Z" }, "embedded": { "sumsub": { "access_token": "", "user_id": "AA45N8G4MLDYWAR7", "expires_at": "2026-07-29T18:00:00Z" } } } } } ``` Redirect the user to `methods.web.url`, or initialise Sumsub with the data in `methods.embedded.sumsub`. If no verification is required, the response contains `not_required` instead: ```json theme={null} { "data": { "not_required": {} } } ``` Use this operation for initial onboarding, a failed attempt, or a later verification requirement. PWS selects the required verification flow from the user's current KYC state. Each call returns the currently available methods or `not_required`, and reuses provider state when possible. ## Reconcile status The hosted response does not contain a verification ID. Obtain `verification_id` from a [`verification.updated`][verification-updated-event] payload, or find the latest hosted attempt with [List Verifications][list-verifications]. Then fetch that verification: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/users/$USER_ID/verifications/$VERIFICATION_ID" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" ``` Fetch the user's overall verification state and required actions separately: ```bash theme={null} curl -X GET "https://api.services.payward.com/v1/users/$USER_ID/verifications/requirements" \ -H "API-Key: $PWS_API_KEY" \ -H "API-Nonce: $PWS_API_NONCE" \ -H "API-Sign: $PWS_API_SIGN" ``` Each item returned by [Get Verification][get-verification] or [List Verifications][list-verifications] includes a `types` array describing the document roles it covers. Its values are `proof_of_identity`, `proof_of_address`, `proof_of_liveness`, `validated_tax_id`, and `other`. A `wait` action means Payward is performing additional review. Call [Create Hosted Verification][create-hosted-verification] only when `user_action_required` is `true` and `required_actions` contains an `action_type: "verification"` item. Register [webhooks][register-webhook] to avoid continuous polling. Subscribe to [`verification.requirements_updated`][verification-requirements-updated-event], then call [Get Verification Requirements][get-verification-requirements] when it arrives. Subscribe to [`verification.updated`][verification-updated-event], then call [Get Verification][get-verification] with the payload's `user_id` and `verification_id`. The webhook payloads contain only the IDs required for those follow-up calls. Treat them as notifications because delivery can be delayed, duplicated, or reordered. The GET response is the current state. ## API reference | Endpoint or event | Use it to | | ------------------------------------------------------------------------------------- | ------------------------------------ | | [`POST /v1/users`][create-user] | Create the user | | [`GET /v1/users/{user_id}`][get-user] | Read account-owned user data | | [`GET /v1/users/{user_id}/verifications/requirements`][get-verification-requirements] | Read KYC state and required actions | | [`POST /v1/users/{user_id}/verifications/hosted`][create-hosted-verification] | Get hosted methods or `not_required` | | [`GET /v1/users/{user_id}/verifications/{verification_id}`][get-verification] | Read one verification attempt | | [`GET /v1/users/{user_id}/verifications`][list-verifications] | List verification attempts | | [`POST /v1/webhooks`][register-webhook] | Register a webhook | | [`verification.requirements_updated`][verification-requirements-updated-event] | Learn that requirements changed | | [`verification.updated`][verification-updated-event] | Learn that a verification changed | [create-user]: /api-reference/users/create-user [get-user]: /api-reference/users/get-user [get-verification]: /api-reference/verifications/get-verification [get-verification-requirements]: /api-reference/verifications/get-verification-requirements [list-verifications]: /api-reference/verifications/list-verifications [register-webhook]: /api-reference/webhooks/register-webhook [create-hosted-verification]: /api-reference/verifications/create-hosted-verification [authentication]: /tabs/developer-documentation/get-started/authentication [verification-updated-event]: /api-reference/webhooks/verification-updated-event [verification-requirements-updated-event]: /api-reference/webhooks/verification-requirements-updated-event