Using Payday
Withdrawals
Deposits settle to the Payday wallet on whichever network the payer chose. A withdrawal moves everything it holds, on every network, to one address you name, with nothing deducted. Payday relays and pays the gas; your signature decides where each leg's funds may land.
How a withdrawal works#
The Payday wallet is an ordinary account with the same address on every network, and its key is yours: Privy holds it for you, Payday never has it. So Payday cannot simply move your funds, and by design it cannot. Instead a withdrawal is four steps, the same over the API as in the dashboard:
Prepare
POST /v1/withdrawalsreads the wallet's USDC on every network and answers with one leg per network that holds any. A leg is atransferwhen the funds already sit on the destination network, and abridgewhen they cross through Circle's CCTP. Each leg carries the EIP-712 document to sign.Sign
The document is an EIP-3009 authorization under that network's USDC contract:TransferWithAuthorizationnaming your destination as the payee, orReceiveWithAuthorizationnaming Payday'sWithdrawalForwarderas the payee with a nonce that commits to your destination. The dashboard signs with the wallet Privy holds; a server signs with the exported key.Submit
POST /v1/withdrawals/{id}/authorizationstakes the signatures. Payday verifies each against your wallet and hands the legs to its relayer.Poll
GET /v1/withdrawals/{id}follows every leg to the destination: the transfer, or the burn, Circle's attestation, and the mint.
Timing and cost#
| Leg | What happens | Typical time |
|---|---|---|
| Transfer | One transferWithAuthorization on the destination network. | Seconds |
| Bridge from Monad | Burn, Circle attests once the block is final, mint on the destination. | Under a minute |
| Bridge from Base or Arbitrum | Same, but Circle waits for Ethereum finality (~65 blocks) before attesting. | 15–20 minutes |
You receive the full amount: CCTP's standard transfer has no protocol fee, and Payday pays every transaction's gas. One withdrawal may be open per account; its authorizations are valid for 24 hours, and a leg left unsigned that long expires.
From the dashboard#
The Account section shows the wallet's balance on each network and a Withdraw control beneath them. Pick the destination network, enter the address, review the legs, and sign once per network. The page checks each document against its leg before it asks the wallet, then tracks every leg until it lands; you can leave while a bridge waits for Circle.
From your server#
A server needs the wallet's key. Export it once from the Account section (Export wallet key): Privy shows it to you on its own origin, and Payday never sees it. Anyone holding it controls the wallet, so keep it in a secret manager next to your API key and rotate the API key if either leaks.
PAYDAY_WALLET_KEY=0x… # exported once from the dashboard; store it in a secret managerThen the four steps, in the official SDK or in any language with an HTTP client and an EIP-712 library. Every uint256 in the document is a decimal string, the nonce is 0x hex, and the signature is 65 bytes r || s || v with v 27 or 28, sent as 0x hex.
import { PaydayClient } from "@payday/sdk";
import { privateKeySigner, signWithdrawal } from "@payday/sdk/signing"; // needs viem installed
const payday = new PaydayClient({ apiKey: process.env.PAYDAY_API_KEY! });
// The Payday wallet key, exported once from the dashboard's Account section.
const signer = await privateKeySigner(process.env.PAYDAY_WALLET_KEY!);
// 1. Prepare: one leg per network the wallet holds USDC on.
let withdrawal = await payday.withdrawals.create(
{ destination: { chain_id: "8453", address: "0x1111111111111111111111111111111111111111" } },
crypto.randomUUID(),
);
// 2. Sign: checks every document against its leg, then signs it.
const chains = (id: number) => deploymentChains.get(id) ?? null; // trusted USDC/CCTP registry
const { authorizations } = await signWithdrawal(withdrawal, signer, { chains });
// 3. Submit, then 4. poll until every leg has landed.
withdrawal = await payday.withdrawals.authorize(withdrawal.id, authorizations);
while (withdrawal.status === "in_progress") {
await new Promise((resolve) => setTimeout(resolve, 10_000));
withdrawal = await payday.withdrawals.get(withdrawal.id);
}
console.log(withdrawal.status, withdrawal.legs.map((leg) => [leg.state, leg.mint_tx_hash ?? leg.transfer_tx_hash]));Another language? Hand your agent the whole specification, examples included, and let it build the same four steps there.
What to check before signing#
The document says where funds go, so a signer should trust the leg, not the document. The SDK's signWithdrawal and the dashboard both refuse a document that fails any of these; do the same:
- typed_data.message.from is your Payday wallet (the withdrawal's wallet_address).
- typed_data.domain.chainId is the leg's source_chain.id, and verifyingContract is that chain's native USDC.
- typed_data.message.value equals the leg's amount_base_units, and validAfter is "0".
- typed_data.message.validBefore is still in the future.
- For a transfer leg, typed_data.message.to is your destination address.
- For a bridge leg, typed_data.message.to and authorization.forwarder equal the trusted WithdrawalForwarder for the source chain; nonce_preimage.destination_domain equals the trusted destination chain's CCTP domain; nonce_preimage.mint_recipient is your destination address; and typed_data.message.nonce equals keccak256(abi.encode(uint32 destination_domain, bytes32(mint_recipient), bytes32 salt)) over nonce_preimage.
The forwarder on each network:
| Network | CCTP domain | WithdrawalForwarder |
|---|---|---|
| This deployment bridges nothing; withdrawals stay on their network. | ||
Test vector#
A bridge leg exactly as the API returns it. Its document hashes to the EIP-712 digest 0xfe0bcc7d9e69ee02881011f29a4156caa15e02b8e94c2e0c9f40e66711651993, and its nonce 0x18b79105e486e10f626b71939a0226c47316949b7c24ff1c397661ea861fafaa is keccak256(abi.encode(uint32 6, bytes32(0x…d00d), bytes32(0x…07))) over the preimage. Payday's Rust, Solidity, TypeScript and browser code all pin these two values; an implementation that reproduces them signs what Payday expects.
{
"id": "wdl_0198f80c-8d2f-7dc1-a369-90556a64f700",
"kind": "bridge",
"source_chain": { "id": "143", "name": "Monad", "native_symbol": "MON" },
"amount": "1.234567",
"amount_base_units": "1234567",
"state": "awaiting_signature",
"authorization": {
"primary_type": "ReceiveWithAuthorization",
"typed_data": {
"domain": {
"name": "USDC",
"version": "2",
"chainId": 143,
"verifyingContract": "0x754704Bc059F8C67012fEd69BC8A327a5aafb603"
},
"primaryType": "ReceiveWithAuthorization",
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"ReceiveWithAuthorization": [
{ "name": "from", "type": "address" },
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" },
{ "name": "validAfter", "type": "uint256" },
{ "name": "validBefore", "type": "uint256" },
{ "name": "nonce", "type": "bytes32" }
]
},
"message": {
"from": "0x1111111111111111111111111111111111111111",
"to": "0x2222222222222222222222222222222222222222",
"value": "1234567",
"validAfter": "0",
"validBefore": "1800000000",
"nonce": "0x18b79105e486e10f626b71939a0226c47316949b7c24ff1c397661ea861fafaa"
}
},
"expires_at": "2027-01-15T08:00:00Z",
"forwarder": "0x2222222222222222222222222222222222222222",
"nonce_preimage": {
"destination_domain": 6,
"mint_recipient": "0x000000000000000000000000000000000000d00d",
"salt": "0x0000000000000000000000000000000000000000000000000000000000000007"
}
},
"transfer_tx_hash": null,
"burn_tx_hash": null,
"mint_tx_hash": null,
"failure_reason": null
}States#
| Leg state | Meaning |
|---|---|
awaiting_signature | Prepared; authorization is present. |
authorized | Signed and queued for the relayer. |
relaying | The transfer or burn is in flight. |
burned, attested, minting | Bridge legs only: burned and waiting for Circle; attested and queued to mint; mint in flight. |
completed | Landed. transfer_tx_hash or burn_tx_hash + mint_tx_hash name the transactions. |
failed, expired, cancelled | Terminal without landing; failure_reason says why. Funds stay where the last successful step left them. |
The withdrawal itself is awaiting_signature while any leg needs a signature, in_progress while the relayer works, then completed, failed or cancelled. The API reference starts at Create a withdrawal.