Payout methods
PayPal, crypto, bank, gift cards.
Each method is off until you turn it on. PayPal can be paid automatically through the PayPal Payouts API; crypto, bank transfers and gift cards, and PayPal without API credentials, are paid by you and then marked paid in the console.
01The four methods
Console → Payout methods (the methods map of config/public.wallet, admin/src/manifest40.ts). Amounts are whole cents of the wallet currency.
| Method | Default minimum | Processing text shown to users | The user enters | Who sends the money |
|---|---|---|---|---|
| PayPal | 500 (5.00) | 1-3 business days | A PayPal e-mail address | The PayPal Payouts API (optional), or you by hand |
| Crypto | 1000 (10.00) | 1-3 business days | An asset from your list and a wallet address | You, from your own wallet |
| Bank transfer | 2500 (25.00) | 3-7 business days | The bank fields you list | You, from your own bank |
| Gift cards | the card's face value | within 48 hours | A card and one of its face values | You, with a code from your supplier |
For every method you also set:
- Offer this method: on or off (off by default).
- Minimum request (not for gift cards).
- Fixed fee and fee % charged to the user (not for gift cards): fee = fixed + amount × % (up to two decimals), rounded down to whole cents; the user receives amount − fee, which must be above 0 (
firebase/functions/src/wallet/money.ts). Both default to 0. - Name in the app and processing time shown to the user.
- Only in these countries: two-letter codes, empty = all. The country is the one the user picks in the payout sheet (it starts at the device region). It is a declaration, not a location check.
PayPal fees, crypto network fees, bank charges and gift-card purchase prices are what you pay. The fee fields above are only what you choose to charge users. Check your providers' current pricing yourself.
02PayPal Payouts API, step by step
Optional. With it, an approved PayPal request is sent by PayPal when the owner presses Send via PayPal. Without it you pay each PayPal request yourself (Manual payouts). Steps from firebase/README.md §13 and firebase/functions/src/wallet/paypal.ts, which records the PayPal documentation it was checked against on 2026-10-07.
- A PayPal Business account with PayoutsPayouts is a PayPal Business feature. Live accounts need PayPal's approval for Payouts; ask PayPal if your account does not have it. Follow PayPal's User Agreement and Payouts terms.
- Create a REST app, Sandbox firstSign in at developer.paypal.com → Apps & Credentials → Sandbox tab → Create App. Open the app and copy its Client ID and Secret. Sandbox and live are different apps with different credentials.
- Enter the credentials in the consoleConsole → PayPal Payouts (owner only): PayPal environment = Sandbox (test money), PayPal REST app client id, PayPal REST app secret (write-only). Press the secret's Test button: it asks PayPal for an OAuth token in the chosen environment and reports ok or the error. The Status page has the same check, "PayPal Payouts credentials answer (OAuth token)". The token is never shown or stored.
- Add the webhookIn the same REST app → Webhooks → Add webhook. URL:
Replacehttps://REGION-PROJECT.cloudfunctions.net/paypalWebhookREGION-PROJECTwith your region and project id (for exampleus-central1-my-project;firebase deployprints the exact URL). Select these events:PAYMENT.PAYOUTS-ITEM.SUCCEEDED PAYMENT.PAYOUTS-ITEM.FAILED PAYMENT.PAYOUTS-ITEM.RETURNED PAYMENT.PAYOUTS-ITEM.BLOCKED PAYMENT.PAYOUTS-ITEM.CANCELED PAYMENT.PAYOUTS-ITEM.REFUNDED PAYMENT.PAYOUTS-ITEM.UNCLAIMED PAYMENT.PAYOUTS-ITEM.HELD PAYMENT.PAYOUTSBATCH.DENIED - Copy the webhook idPayPal shows an id for the new webhook. Paste it into console → PayPal Payouts → PayPal webhook id. The function checks every webhook with PayPal's
verify-webhook-signatureAPI using this id; an unverified call gets 401 and changes nothing. - Test in sandboxCreate a sandbox personal account in the developer dashboard (Sandbox → Accounts) and use its e-mail as the destination of a test request. Approve it, press Send via PayPal as the owner, and watch it go to
processingand thenpaid. Try a failing case too. - Go liveCreate the app on the Live tab, enter its client id and secret, set the environment to Live (real money), add a live webhook with the same URL and events, and paste the live webhook id. Fund your PayPal balance before you send.
What happens when the owner sends
- The request goes from
approvedtoprocessingand the backend callsPOST /v1/payments/payoutswith one item: the user's e-mail and the net amount in the wallet currency. The request id is thesender_batch_id; PayPal refuses a reused id for 30 days, so a retry can never pay twice. - The webhook settles it: SUCCEEDED →
paid; FAILED, RETURNED, BLOCKED, CANCELED, REFUNDED or a denied batch →failedand the amount returns to the user's available balance; UNCLAIMED, HELD → staysprocessing(PayPal returns unclaimed money after 30 days, which arrives as RETURNED). A RETURNED or REFUNDED afterpaidmoves it tofailedand refunds the wallet. pollPaypalPayoutschecks processing batches every 30 minutes in case a webhook is missed.- A send error (for example an insufficient PayPal balance) puts the request back to
approvedwith the error inprovider.lastError. The Payouts page shows "Last PayPal error". Fix the cause and press Retry PayPal send (owner), or reject the request.
paypalWebhook answers 401 for a bad signature, 400 for bad JSON, 503 while PayPal is not configured, 405 for anything but POST, and 200 otherwise. Events are de-duplicated (paypalEvents). Firebase console → Functions → logs shows the details.
03Manual payouts: crypto, bank, gift cards (and PayPal by hand)
The backend never stores or asks for a private key, a seed phrase or a bank login. You send the money with your own tools and record it.
- ApproveConsole → Payouts → open the request → Approve (manager or owner).
- See where to send itLists show a masked destination. Press Show full destination to see what the user entered; this is audited and refused on the demo console.
- Send the money yourselfFrom your crypto wallet (check the asset and network match), your bank, PayPal's website, or buy the gift card from your supplier.
- Mark paidPress Mark paid and enter the reference: the transaction hash (crypto), the bank reference, the gift-card code, or the PayPal transaction id. Optional: your cost, what the payout really cost you (for example a discounted gift card); the Business page then uses it. Mark paid only after the money has really left.
Marking paid moves the amount out of the user's locked balance, adds it to lifetime paid, writes a cost event (payoutEvents) and an inbox message. Reject (with a reason the user sees) returns the amount to the user's available balance.
Crypto assets
Console → Payout methods → Crypto assets, one per line as id|name|address pattern. Defaults:
usdt-trc20|USDT (TRON)|^T[1-9A-HJ-NP-Za-km-z]{33}$
usdt-polygon|USDT (Polygon)|^0x[0-9a-fA-F]{40}$
usdc-base|USDC (Base)|^0x[0-9a-fA-F]{40}$
btc|Bitcoin|^(bc1[0-9a-z]{11,71}|[13][1-9A-HJ-NP-Za-km-z]{25,34})$
ltc|Litecoin|^(ltc1[0-9a-z]{11,71}|[LM3][1-9A-HJ-NP-Za-km-z]{26,33})$
The id is lower-case letters, digits, - and _ (up to 40). The pattern is a regular expression from ^ to $, up to 300 characters; the app and the backend check every address with it (reason bad_address). A line that is not valid is skipped and listed on the Status page. A pattern checks the format only, not that the address exists or is on the right network: double-check before you send. Amounts are in the wallet currency; you decide the exchange rate when you send.
Bank fields
Console → Payout methods → Bank details the user fills in, one field id per line. Default accountHolder, iban, bic; for US accounts for example accountHolder, accountNumber, routingNumber. Every field is required (up to 64 characters). A field named iban is checked with the IBAN checksum (reason bad_iban).
Gift-card catalog
Console → Payout methods → Gift-card catalog, one card per line as id|brand|face values in cents|countries|your cost in cents, for example store-card|Store card|500,1000,2500|US,CA|. The brand is text you type: the app shows no logo you do not own, so do not add third-party logos. Gift cards stay hidden while the catalog is empty. The user always pays the face value (no fee); "your cost" is for your own records.
Only list brands whose cards you can legally buy and pass on to users in their countries, under the brand's and your supplier's terms.
04Gift-card codes are stored encrypted
- The code you enter at Mark paid is encrypted with AES-256-GCM before it is stored (
provider.giftcardCode,firebase/functions/src/wallet/codes.ts). The console's copy of your action is overwritten with "[stored encrypted]". - The key is generated by the backend the first time a code is stored and kept in
config/private.wallet.codeKey. You never type it, and the console keeps it when it rewritesconfig/private. - Only the user who owns the request can read the code, in the app (
revealGiftCode); the first reveal is recorded. The inbox message only says the card is ready; the code is never written in clear anywhere. The console help says "shown to the user once", but as built the owner can reveal it again later.
If config/private.wallet.codeKey is lost, stored codes cannot be decrypted. Include config/private in your Firestore backups and never delete that field by hand.
05Limits that apply to every method
Console → Wallet & payouts. Checked by requestPayout on the server (firebase/functions/src/wallet/payouts.ts):
| Setting | Default | Effect |
|---|---|---|
| Payout limit per user per day (cents) | 5000 | All methods together, UTC day. 0 = no limit. |
| Payout limit per user per month (cents) | 50000 | UTC month. 0 = no limit. |
| Account age needed for a payout request (days) | 3 | Requests from younger accounts are refused (account_age). |
| First payout needs a verified e-mail (or Apple/Google sign-in) | on | Guest accounts must link Google or Apple first (email_unverified). As built the check runs on every request, not only the first. |
| Always review requests above (cents) | 2000 | Even with auto-approve on, a bigger request waits for you. |
| Auto-approve small requests | off | See Auto-approve. |
Also fixed in code: 3 requests per user per hour; one request per clientRequestId (a repeated tap returns the same request); the amount must not exceed the available balance. More in Fraud signals and limits.