Skip to main content
Check out working demo: Live Demo
Repo: privy-machines-cards-example
Deploy with Vercel
This guide is for partners already using Privy embedded wallets. Scope:
  • Partner API integration (/partner/v1) only.
  • Server-side Privy wallet control for signing and sending transactions.
  • Not a guide for Machines first-party web auth endpoints like /v1/auth/email/exchange.

Architecture

  • Your backend calls Machines Partner API.
  • Your backend also calls Privy server APIs for embedded wallet signing/transaction execution.
  • End users do not need wallet popups for embedded-wallet withdrawal execution paths.

Prerequisites

  • Privy app configured for your environment.
  • Privy embedded Ethereum wallets enabled.
  • Privy authorization key configured for server-side wallet access.
  • Machines partner API key.
  • Partner backend can securely store:
    • Machines partner key
    • Privy app secret
    • Privy authorization private key

Privy Embedded Wallet Checklist (Server-Side)

Before sending live traffic, confirm:
  1. Wallet control path
    • You can resolve each user’s Privy embedded Ethereum wallet.
    • Your backend can sign/send on that wallet using an authorization context (authorization key, user JWT, or a custom sign function).
  2. Signer binding
    • The wallet is configured with a signer path your backend can use (for example an authorization-key signer).
    • The authorization private key is in a secrets manager, never in client code.
  3. Policy attachment
    • Policy IDs for withdrawal execution are attached to the signer/wallet.
    • Rules cover eth_sendTransaction and eth_signTypedData_v4.
  4. Sponsorship setup
    • Gas sponsorship is enabled in Privy Dashboard for the chains you support.
    • Your backend passes sponsor: true for sponsored sends.
  5. Status monitoring
    • You have webhook or polling support for transaction status reconciliation.
Per user, persist at least:
  • machinesUserId (your Machines partner user mapping)
  • privyUserId (Privy user id)
  • embeddedWalletAddress (and walletId if you store it)
  • linkedAt / lastUsedAt
Per execution, persist:
  • partner idempotency key
  • Privy transaction_id / user operation hash (if returned)
  • final onchain transaction_hash
  • execution path (controller_v1 or coordinator_v2)

End-to-End Partner Flow

Call POST /partner/v1/users/resolve from your backend.
curl --request POST \
  --url https://api.machines.cash/partner/v1/users/resolve \
  --header 'Content-Type: application/json' \
  --header 'X-Partner-Key: <PARTNER_API_KEY>' \
  --data '{
    "userId": "partner-user-123",
    "walletAddress": "0xabc...",
    "walletLabel": "Privy Embedded Wallet"
  }'

2) Create a scoped partner session

Call POST /partner/v1/sessions. Use the smallest scope set needed for the current operation.
curl --request POST \
  --url https://api.machines.cash/partner/v1/sessions \
  --header 'Content-Type: application/json' \
  --header 'X-Partner-Key: <PARTNER_API_KEY>' \
  --data '{
    "userId": "partner-user-123",
    "walletAddress": "0xabc...",
    "scopes": ["deposits.read", "withdrawals.write"],
    "ttlSeconds": 900
  }'

3) Run core Machines flows

Use the partner session token for:
  • KYC
  • Agreements
  • Cards
  • Balances
  • Deposits
This guide focuses on withdrawals because that is where embedded-wallet server signing is most relevant.

4) Withdrawal flow (partner + Privy execution)

Call sequence:
  1. GET /partner/v1/withdrawals/assets
  2. POST /partner/v1/withdrawals/range
  3. POST /partner/v1/withdrawals/estimate
  4. POST /partner/v1/withdrawals (include adminAddress)
  5. Execute onchain via Privy wallet APIs
Create request example:
curl --request POST \
  --url https://api.machines.cash/partner/v1/withdrawals \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <PARTNER_SESSION_TOKEN>' \
  --header 'Idempotency-Key: wdrl-001' \
  --data '{
    "amountCents": 2500,
    "source": {
      "contractId": "optional-uuid"
    },
    "destination": {
      "currency": "hbar",
      "network": "hbar",
      "address": "0.0.123456",
      "extraId": "optional-tag"
    },
    "adminAddress": "0xabc..."
  }'
Use the same Idempotency-Key when retrying POST /partner/v1/withdrawals after pending responses.

Execution Model and Contract Call Paths

Machines withdrawal create response includes:
  • execution.callTarget
  • execution.callPath (controller_v1 or coordinator_v2)
  • parameters (7-arg Rain executor payload)
Always send the transaction to execution.callTarget.
  • controller_v1:
    • Call 7-arg withdrawAsset(...)
    • Selector: 0xe167d26a
    • Signature: withdrawAsset(address,address,uint256,address,uint256,bytes32,bytes)
  • coordinator_v2:
    • Build admin typed-data signature first
    • Call 10-arg withdrawAsset(...)
    • Selector: 0x4b268241
    • Signature: withdrawAsset(address,address,uint256,address,uint256,bytes32,bytes,bytes32[],bytes[],bool)

v2 Typed Data Shape

const domain = {
  name: "Collateral",
  version: "2",
  chainId,
  verifyingContract: collateralProxy,
  salt: adminSalt, // bytes32
};

const types = {
  Withdraw: [
    { name: "user", type: "address" },
    { name: "asset", type: "address" },
    { name: "amount", type: "uint256" },
    { name: "recipient", type: "address" },
    { name: "nonce", type: "uint256" },
  ],
};

const message = {
  user: adminAddress,
  asset: tokenAddress,
  amount,
  recipient,
  nonce: adminNonce,
};

Partner-side Privy Execution (TypeScript)

// 1) Create withdrawal payload at Machines
const wd = await fetch("https://api.machines.cash/partner/v1/withdrawals", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${partnerSessionToken}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify({
    amountCents,
    destination,
    adminAddress,
  }),
}).then((r) => r.json());

if (wd?.data?.status === "pending") {
  // retry same request with same idempotency key
}

const execution = wd?.data?.execution;
const parameters = wd?.data?.parameters;
if (!execution?.callTarget || !parameters || parameters.length < 7) {
  throw new Error("withdrawal payload not ready");
}
// 2) v2 only: sign typed data with Privy
const adminSignature = await privy.walletApi.ethereum.signTypedData({
  walletId,
  chainType: "ethereum",
  typedData: {
    domain,
    types,
    message,
    primaryType: "Withdraw",
  },
});
// 3) Send tx with Privy (v1 or v2 encoded calldata)
const send = await privy.walletApi.rpc({
  walletId,
  chainType: "ethereum",
  caip2: `eip155:${chainId}`,
  method: "eth_sendTransaction",
  sponsor: true,
  idempotencyKey,
  params: {
    transaction: {
      from: adminAddress,
      to: execution.callTarget,
      data: encodedCallData,
      value: "0x0",
    },
  },
});

const transactionId = send?.data?.transactionId;
let txHash = send?.data?.hash;
if (!txHash && transactionId) {
  // poll Privy getTransaction(transactionId) until hash is available
}

Privy Method Allowances and Policy Design

For this flow, allow at minimum:
  • eth_sendTransaction
  • eth_signTypedData_v4 (needed for coordinator_v2)
Use dynamic target address rules:
  • Do not hardcode a single contract address.
  • Restrict to to the execution.callTarget values returned by Machines for supported chains/contracts.
Recommended condition dimensions:
  • ethereum_transaction:
    • chain_id in allowed chains
    • to in your dynamically maintained allowlist
    • value == 0
  • ethereum_calldata:
    • ABI for Rain withdrawal functions
    • function_name == "withdrawAsset"
  • ethereum_typed_data_domain and ethereum_typed_data_message (for v2):
    • domain name/version chain checks
    • typed message fields constrained to your expected flow
Policy modeling tip:
  • Keep reusable condition sets per chain + function family.
  • Compose those sets into authorization policies instead of duplicating large JSON blocks.

Example Policy Shape (Ethereum)

This is a concrete starting shape aligned with Privy’s Ethereum policy examples:
{
  version: "1.0",
  name: "Machines withdrawal execution",
  chain_type: "ethereum",
  rules: [
    {
      name: "Allow withdrawAsset tx to approved call targets",
      method: "eth_sendTransaction",
      action: "ALLOW",
      conditions: [
        {
          field_source: "ethereum_transaction",
          field: "chain_id",
          operator: "in",
          value: ["8453", "84532"]
        },
        {
          field_source: "ethereum_transaction",
          field: "to",
          operator: "in_condition_set",
          value: "<CALL_TARGET_CONDITION_SET_ID>"
        },
        {
          field_source: "ethereum_transaction",
          field: "value",
          operator: "eq",
          value: "0x0"
        },
        {
          field_source: "ethereum_calldata",
          field: "function_name",
          abi: [
            {
              name: "withdrawAsset",
              type: "function",
              stateMutability: "nonpayable",
              inputs: [
                { name: "collateralProxy", type: "address" },
                { name: "token", type: "address" },
                { name: "amount", type: "uint256" },
                { name: "recipient", type: "address" },
                { name: "expiresAt", type: "uint256" },
                { name: "executorSalt", type: "bytes32" },
                { name: "executorSignature", type: "bytes" }
              ],
              outputs: []
            },
            {
              name: "withdrawAsset",
              type: "function",
              stateMutability: "nonpayable",
              inputs: [
                { name: "collateralProxy", type: "address" },
                { name: "token", type: "address" },
                { name: "amount", type: "uint256" },
                { name: "recipient", type: "address" },
                { name: "expiresAt", type: "uint256" },
                { name: "executorSalt", type: "bytes32" },
                { name: "executorSignature", type: "bytes" },
                { name: "adminSalt", type: "bytes32[]" },
                { name: "adminSignature", type: "bytes[]" },
                { name: "directTransfer", type: "bool" }
              ],
              outputs: []
            }
          ],
          operator: "eq",
          value: "withdrawAsset"
        }
      ]
    },
    {
      name: "Allow v2 domain-constrained typed data signing",
      method: "eth_signTypedData_v4",
      action: "ALLOW",
      conditions: [
        {
          field_source: "ethereum_typed_data_domain",
          field: "chainId",
          operator: "in",
          value: ["8453", "84532"]
        },
        {
          field_source: "ethereum_typed_data_domain",
          field: "verifyingContract",
          operator: "in_condition_set",
          value: "<COLLATERAL_PROXY_CONDITION_SET_ID>"
        },
        {
          field_source: "ethereum_typed_data_message",
          typed_data: {
            types: {
              Withdraw: [
                { name: "user", type: "address" },
                { name: "asset", type: "address" },
                { name: "amount", type: "uint256" },
                { name: "recipient", type: "address" },
                { name: "nonce", type: "uint256" }
              ]
            },
            primary_type: "Withdraw"
          },
          field: "amount",
          operator: "lte",
          value: "<HEX_MAX_ALLOWED_AMOUNT>"
        }
      ]
    }
  ]
}
Practical guidance:
  • Use condition sets for to addresses and verifying contracts.
  • Keep policy values as strings matching Privy examples (chain_id, hex values, address strings).
  • Treat this policy as an allowlist; add explicit DENY rules only when needed.

Gas Sponsorship and Transaction Lifecycle

  • Use sponsor: true for embedded wallet sends to sponsor gas.
  • For gas-sponsored EVM sends, backend responses can return before final onchain hash.
  • Handle async status fields:
    • transaction_id (Privy transaction id)
    • user_operation_hash
    • hash may be empty until confirmation.
  • Track final status via:
    • Privy webhooks (recommended)
    • or transaction-status API polling by transaction_id.
  • Add operational monitoring:
    • submission failures
    • hash timeouts
    • onchain confirmation delays
    • spend anomalies and abuse patterns

Known Constraints and Gotchas

  • Production destination coverage (BTC/SOL/EVM/etc.) depends on live relay routes. Always query:
    • GET /partner/v1/withdrawals/assets
    • POST /partner/v1/withdrawals/range
    • POST /partner/v1/withdrawals/estimate before quoting or creating withdrawals.
  • Sandbox source is fixed to rUSD on Base Sepolia.
  • If withdrawal signature response is status: pending, retry with the same idempotency key.

Further Reading (Privy)