# BLOX Documentation BLOX Documentation Welcome to BLOX Malaysia's trusted gateway to the on-chain economy. Deposit, withdraw, and manage MYRC with Payment Gateway (FPX) — backed by MYRC, the Malaysian Ringgit stablecoin where 1 MYRC always equals 1 MYR. ## Choose Your Path ## Why BLOX? BLOX is Malaysia's compliant MYRC platform built for real-world use. We make it simple to move between Ringgit and MYRC.

MYRC Stablecoin

1 MYRC = 1 MYR, always. A fully-backed Malaysian Ringgit stablecoin you can trust.

Payment Gateway (FPX)

Pay with any Malaysian bank via our secure gateway. MYRC arrives in your wallet within minutes.

Multi-Chain Support

Use MYRC on Ethereum, Arbitrum One, Base, or Solana — pick the network that suits your needs.

Bank Withdrawals

Withdraw MYRC and receive MYR directly in your Malaysian bank account. No hassle.

## Supported Networks Active networks match what `GET /v1/wallet/networks` returns. Currently live: | Network | Chain ID | Environment | |---------|----------|-------------| | Ethereum | 1 | Production | | Arbitrum One | 42161 | Production | | Base | 8453 | Production | | Solana | - | Production | | Ethereum Sepolia | 11155111 | Sandbox | | Arbitrum Sepolia | 421614 | Sandbox | | Solana Devnet | - | Sandbox | ## Get Started 1. **Create Account** — Sign up at [blox.my](https://blox.my) in under 2 minutes 2. **Verify with MyKad** — Instant eKYC verification 3. **Link Your Bank** — Connect any Malaysian bank account 4. **Deposit & Withdraw** — Handle MYRC with **Payment Gateway (FPX)** or Bank Transfer 1. **Get API Keys** — Contact the BLOX team to enable API key access (sandbox and production) 2. **Explore the APIs** — Wallet, Onramp (Checkout), and Payout 3. **Test in Sandbox** — Full-featured test environment with testnet tokens 4. **Go Live** — Switch to production when you're ready ## Need Help? For help with your account or a transaction, email [support@blox.my](mailto\:support@blox.my) with your account email and a short description of the issue. Please do not include passwords or verification codes. For partnerships, business inquiries, or API access, email [business@blox.my](mailto\:business@blox.my). # Getting Started with BLOX Welcome to BLOX — the easiest way for Malaysians to deposit, withdraw, and manage MYRC. Pay with **Payment Gateway (FPX)**, withdraw to your bank, and keep your assets secure in one simple wallet. ## What Can You Do with BLOX?

Deposit MYRC

Add funds via **Payment Gateway (FPX)** or blockchain transfer. Your MYRC arrives in minutes.

Withdraw to Bank

Convert MYRC back to MYR and withdraw directly to your Malaysian bank account. T+1 settlement.

Secure Wallet

Your assets are protected with enterprise-grade security and 2FA authentication.

Withdraw to Wallet

Send MYRC to any wallet on **Ethereum**, **Arbitrum**, **Base**, or **Solana** with low network fees.

## Quick Start Guide ### Step 1: Create Your Account Sign up at [blox.my](https://blox.my). You'll need: * A valid email address * A secure password (min. 8 characters) * An optional invite code After signing up, check your inbox for a **verification link** to activate your account. ### Step 2: Complete eKYC Verification Verify your identity instantly using our secure eKYC process: 1. Select your document type (**MyKad** or **Passport**) 2. Enter your full name and ID number 3. Complete the automated verification flow > \[!NOTE] > eKYC verification is typically processed instantly, allowing you to move to the next step immediately. ### Step 3: Set Up Your Account Once verified, finalize your account setup: 1. Create an **Account Nickname** (Short Name) for your wallet 2. View your individual transaction limits ### Step 4: Deposit or Withdraw MYRC Now you're ready to transact: * **Deposit MYRC**: Add funds via **Payment Gateway** (Instant), Bank Transfer, or Blockchain Wallet * **Withdraw MYRC**: Withdraw MYR to your bank account (T+1) or send to an external wallet > \[!IMPORTANT] > For security, withdrawals require Multi-Factor Authentication (MFA) to be enabled in your **Settings**. ## What is MYRC? MYRC is a **Malaysian Ringgit stablecoin** — a digital currency that's always worth exactly 1 MYR. It's fully backed and redeemable, so you can: * **Deposit MYRC** with MYR or other tokens at any time * **Withdraw MYRC** back to MYR or to external wallets whenever you need * **Use MYRC** across **Ethereum**, **Arbitrum**, **Base**, or **Solana** * **Use MYRC** in DeFi apps, payments, or as a stable store of value ## Explore More * [Deposit MYRC](/user/mint-myrc) — How to add MYRC to your wallet * [Withdraw MYRC](/user/burn-myrc) — How to withdraw to bank or external wallets * [Wallet Basics](/user/wallet-basics) — Understanding addresses, networks, and fees * [Address Book](/user/address-book) — Save frequently used wallet addresses * [Bank Accounts](/user/bank-accounts) — Manage your linked bank accounts * [NFT Rewards](/user/nft-rewards) — Earn exclusive NFTs as a BLOX member * [Leaderboard](/user/referrals) — Track your rank and invite friends * [FAQ](/user/faq) — Answers to common questions ## Need Help? * **Email**: support@blox.my * **Response Time**: Within 24 hours on business days # Frequently Asked Questions Find answers to common questions about using BLOX and MYRC. ## General Information ### What is MYRC? MYRC is a digital stablecoin developed by BLOX that is pegged 1-to-1 with the Malaysian Ringgit (MYR). It combines the advantages of blockchain technology with the stability of a fiat currency, providing a secure and reliable digital payment solution in Malaysia. ### How is MYRC backed? MYRC is fully backed by MYR fiat currency held in reserve. For every MYRC token in circulation, an equivalent amount of MYR is securely stored in trustee accounts to ensure stability and 1:1 redeemability. ### What are the benefits of using MYRC? * **Stability**: Pegged to MYR, avoiding the volatility of other cryptocurrencies. * **Fast Settlements**: Faster than traditional interbank transfers for on-chain transactions. * **Cost-Effective**: Reduces fees associated with traditional intermediaries. * **DeFi Ready**: Use MYRC across various decentralized finance applications on Ethereum, Arbitrum, Base, and Solana. ### Can I redeem MYRC for Malaysian Ringgit? Yes. As a holder of MYRC, you can redeem your tokens for an equivalent amount of Malaysian Ringgit. Through the BLOX platform, you can convert your MYRC back to MYR and withdraw it to your bank account anytime. *** ## Account & Verification ### How long does verification take? KYC verification is typically processed **instantly**. You'll be able to deposit and withdraw MYRC as soon as your identity is confirmed. ### Why was my verification rejected? Common reasons for rejection: * Blurry or unclear MyKad photo * Face not clearly visible in selfie * Information mismatch between documents * Document appears altered or damaged To resubmit, go to Settings > Verification and upload new documents. ### Can I have multiple accounts? No. Each user is limited to one account per MyKad. Creating multiple accounts may result in account suspension. *** ## Deposit & Funding ### What's the minimum deposit amount? * **Payment Gateway**: RM 10 * **Bank Transfer**: RM 10 ### How long do deposits take? * **Payment Gateway**: Instant (usually within 1-2 minutes) * **Bank Transfer**: 1-2 business days ### Why is my deposit pending? Deposits may be pending due to: * Bank verification in progress * Incorrect reference number used * Payment from non-registered bank account *** ## Withdraw & Redemption ### How long do withdrawals take? Withdrawals are processed within T+1 working day. Requests made after banking hours or on weekends will be processed the next working day. ### Can I withdraw to someone else's account? No. For security and compliance reasons, withdrawals can only be made to bank accounts registered in your own name. ### What if I entered the wrong bank account? Contact support immediately at support@blox.my. If the withdrawal hasn't been processed yet, we may be able to cancel it. *** ## Wallet & Transactions ### Can I send MYRC to external wallets? Yes! You can send MYRC to any compatible wallet address on supported networks (Ethereum, Arbitrum, Base, Solana). ### What are network fees? Network fees (also called gas fees) are paid to blockchain validators for processing transactions. Fees vary by network: * Ethereum: Higher fees, slower * Arbitrum: Low fees, fast * Base: Low fees, fast * Solana: Very low fees, fastest ### Can I recover MYRC sent to the wrong address? Unfortunately, blockchain transactions are irreversible. Always double-check the recipient address before confirming. *** ## Security ### How do I enable 2FA? 1. Go to Settings > Security 2. Select "Enable Two-Factor Authentication" 3. Scan the QR code with an authenticator app 4. Enter the verification code to confirm ### I forgot my password. What do I do? Click "Forgot Password" on the login page and follow the email instructions to reset your password. ### I think my account was compromised. What should I do? Immediately: 1. Change your password 2. Enable/reset 2FA 3. Contact support@blox.my 4. Check for any unauthorized transactions *** ## Fees & Limits ### What fees does BLOX charge? | Transaction Type | Fee | |-----------------|-----| | Deposit (FPX) | Contact BLOX / shown in-app at time of transaction | | Deposit (Bank Transfer) | Contact BLOX / shown in-app at time of transaction | | Withdraw to Bank | Contact BLOX / shown in-app at time of transaction | | MYRC Transfer (Ethereum / Arbitrum / Base / Solana) | Network fee; platform fee if applicable (shown before confirm) | ### What are my transaction limits? Limits are configured per account and shown in the app before you confirm. For API amount ranges, see [Wallet API](/api/wallet-api/overview), [Onramp API](/api/onramp-api/overview), and [Payout API](/api/payout-api/overview). Confirm daily/monthly limits with support@blox.my. *** ## Still Have Questions? Contact our support team: * **Email**: support@blox.my * **Response Time**: Within 24 hours on business days For developers, check out our [API Documentation](/api/getting-started). # Wallet Basics Learn the fundamentals of your BLOX wallet and how to manage your digital assets safely. ## What is a Wallet? A digital wallet is a secure tool that allows you to: * **Store** your digital assets (like MYRC) * **Send** MYRC to other addresses * **Receive** MYRC from others * **View** your transaction history ## Your BLOX Wallet When you create a BLOX account, you automatically get a secure wallet that supports: | Asset | Network | Description | |-------|---------|-------------| | MYRC | Ethereum | MYR-backed stablecoin on Ethereum mainnet | | MYRC | Arbitrum | MYR-backed stablecoin on Arbitrum L2 | | MYRC | Base | MYR-backed stablecoin on Base L2 | | MYRC | Solana | MYR-backed stablecoin on Solana | ## Understanding Wallet Addresses Your wallet address is like a bank account number for MYRC. It's a unique string of characters that others can use to send you MYRC. **Example EVM (Ethereum / Arbitrum / Base) Address:** ``` 0x1234...abcd ``` **Example Solana Address:** ``` myrc...Mgv ``` > **Important**: Always double-check the wallet address before sending MYRC. Transactions cannot be reversed! ## Security Best Practices ### Do's * Keep your login credentials secure * Enable two-factor authentication (2FA) * Verify addresses before sending MYRC * Use official BLOX apps and website only ### Don'ts * Never share your password or 2FA codes * Don't click suspicious links claiming to be BLOX * Never send MYRC to addresses you don't trust * Don't store large amounts on exchanges ## Transaction Fees When sending MYRC, you'll encounter: | Fee Type | Description | |----------|-------------| | **Network Fee** | Paid to blockchain validators for processing | | **Platform Fee** | BLOX service fee (varies by transaction type) | > Network fees vary based on blockchain congestion. Arbitrum, Base, and Solana typically have lower fees than Ethereum mainnet. ## Viewing Transaction History You can view all your transactions in the BLOX app: 1. Navigate to **Transactions** in the menu 2. Filter by type (deposits, withdrawals, transfers) 3. Click on any transaction for details Each transaction shows: * Date and time * Amount and asset * Transaction status * Network and fees * Blockchain explorer link ## Next Steps * [Deposit MYRC](/user/mint-myrc) — Add MYRC to your wallet * [Withdraw MYRC](/user/burn-myrc) — Convert MYRC back to MYR * [FAQ](/user/faq) - Common questions answered # Deposit MYRC Add MYRC to your BLOX wallet using Malaysian Internet Banking or by transferring from an external blockchain wallet. ## Deposit Methods

Internet Banking

Add funds via **Payment Gateway (FPX)** for instant delivery or **Bank Transfer** for larger amounts.

Blockchain Wallet

Deposit MYRC from an external wallet on Ethereum, Arbitrum, Base, or Solana.

## How to Deposit ### Step 1: Choose Your Method From the sidebar, tap **Deposit**. Choose how you want to fund your account: 1. **Internet Banking** — For FPX or Bank Transfers 2. **Blockchain Wallet** — For incoming on-chain transfers *** ## 1. Deposit via Internet Banking ### Method A: Payment Gateway (FPX) — Recommended * **Best For**: Instant delivery * **Time**: Instant once bank confirmation is received * **Window**: 15 minutes to complete on your bank portal * **Amount**: Limits shown in the app before you confirm ### Method B: Bank Transfer * **Best For**: Larger amounts * **Time**: 1-2 business days * **Required**: Must use the unique **Payment Reference** provided in the app *** ## 2. Deposit from Blockchain Wallet ### Step 1: Select Network Copy your BLOX deposit address for your chosen network: * **Ethereum** * **Arbitrum One** * **Base** * **Solana** ### Step 2: Send from External Wallet Use the copied address in your external wallet (e.g., MetaMask, Phantom) to send MYRC. ### Step 3: Wait for Confirmations Once detected on-chain, your balance will be updated automatically. *** ## Limits & Fees Per-transaction amounts and fees are shown in the app before you confirm. Blockchain deposits are subject to network fees only. For API amount ranges, see [Onramp API](/api/onramp-api/overview) and [Wallet API](/api/wallet-api/overview). ## Need Help? Contact **support@blox.my** if your deposit is not reflected after the expected processing time. # Withdraw MYRC Withdraw your MYRC to your Malaysian bank account via Internet Banking or to an external blockchain wallet. ## Withdrawal Types

Internet Banking

Convert MYRC to MYR and withdraw directly to your linked bank account (T+1 settlement).

Blockchain Wallet

Send MYRC to any compatible wallet address on Ethereum, Arbitrum, Base, or Solana.

## How to Withdraw ### Step 1: Select Your Destination From the sidebar, tap **Withdraw**. Choose your preferred method: 1. **Internet Banking** — For MYR bank withdrawals 2. **Blockchain Wallet** — For on-chain transfers *** ## 1. Withdraw to Bank (Internet Banking) ### Step 1: Select Bank Account Choose a linked Malaysian bank account in your name. ### Step 2: Enter Amount Enter an amount within the limits shown in the app. Fees, if any, are shown before you confirm. For API amount ranges, see [Wallet API](/api/wallet-api/overview). ### Step 3: Confirm with MFA Funds arrive on the **next working day (T+1)**. *** ## 2. Withdraw to Wallet (Blockchain) ### Step 1: Select Network Choose a supported network: **Ethereum**, **Arbitrum**, **Base**, or **Solana**. ### Step 2: Enter Recipient Address Paste the `0x...` address (EVM) or Base58 address (Solana). Use your [Address Book](/user/address-book) for convenience. ### Step 3: Enter Amount Specify how much to send. Be sure to account for small network fees. ### Step 4: Confirm with MFA On-chain transfers are irreversible. **Double-check the address and network before confirming.** *** ## Processing Time & Fees | Method | Speed | Fee | |--------|-------|-----| | Internet Banking | T+1 Working Day | Shown in the app before you confirm | | Blockchain Wallet | Instant (Seconds) | Network fee, plus any platform fee shown before you confirm | ## Common Questions ### It's been 2 days and I haven't received my MYR Check your bank account history for a transaction from **BLOX BC SDN BHD**. If still missing, contact support with your Transaction ID. ### Can I withdraw to someone else's bank account? No. For regulatory compliance, bank withdrawals must go to an account matching your verified legal name. ### Can I cancel a withdrawal? Once confirmed via MFA, withdrawals are processed automatically and cannot be cancelled. ## Need Help? Email **support@blox.my** for any withdrawal or transfer issues. # Paying with Checkout When a merchant sends you a BLOX Checkout link, you can pay directly from your BLOX wallet. This guide walks you through the secure payment process. ## What is a Checkout Link? A checkout link is a secure payment page created by a merchant. When you click the link, you'll see: * **Merchant name** — The business or person requesting payment * **Payment amount** — The exact amount to be transferred * **Payment description** — What you're paying for * **Time remaining** — Checkout links expire after 20 minutes Checkout links look like this: `https://checkout.blox.my/abc123...` *** ## Step-by-Step Payment Guide ### Step 1: Open the Checkout Link Click the payment link sent by the merchant. You'll be directed to a secure checkout page on `checkout.blox.my`. **What you'll see:** * Merchant's business name * Payment amount and currency * Title and description of the payment * A countdown timer showing time remaining > **Security Tip:** Always verify the URL starts with `https://checkout.blox.my/` — this is the only legitimate BLOX checkout domain. *** ### Step 2: Sign In with Your BLOX ID Click **"Sign in with BLOX"** to authenticate. 1. Enter your email address 2. Enter your password 3. If prompted, complete email verification After signing in, you'll see a list of your BLOX wallets. > **Don't have a BLOX account?** Start with the [User Guide](/user/getting-started) before you pay. *** ### Step 3: Select Your Wallet Choose which wallet to pay from. **What you'll see for each wallet:** * Wallet name * Current balance * Whether your balance is sufficient Select the wallet you want to use. The system will verify you have enough funds. > **Tip:** If you have both personal and business wallets, make sure to select the correct one for this payment. *** ### Step 4: Top Up (If Needed) If your selected wallet doesn't have enough funds, you'll see an option to **top up via bank transfer**. 1. Click **"Add Funds"** 2. Choose your deposit amount 3. Complete the payment with **Payment Gateway (FPX)** or a bank transfer 4. Return to the checkout page 5. Your updated balance will be reflected You can also add funds through the main BLOX app and return to the checkout link later (before it expires). *** ### Step 5: Authorize with MFA To complete the payment, enter your 6-digit authentication code. 1. Open your authenticator app (Google Authenticator, Authy, etc.) 2. Find the code for your BLOX account 3. Enter the 6-digit code 4. Click **"Confirm Payment"** > **Why MFA?** Multi-factor authentication protects you from unauthorized payments. Even if someone gains access to your account, they can't complete payments without your authenticator code. *** ### Step 6: Payment Confirmation After successful payment: * You'll see a confirmation screen with transaction details * You'll be automatically redirected to the merchant's website * Funds are transferred directly to the merchant's wallet **Keep for your records:** * Transaction ID * Payment amount * Merchant name * Date and time *** ## Troubleshooting ### "Insufficient Balance" Your selected wallet doesn't have enough funds to complete the payment. **What to do:** 1. Click **"Add Funds"** to top up via bank transfer 2. Or, deposit funds through the main BLOX app 3. Return to the checkout link and try again > Make sure to complete top-up before the checkout link expires (20 minutes). *** ### "Checkout Link Expired" Checkout links are valid for **20 minutes** for security reasons. **What to do:** * Contact the merchant and request a new payment link * Complete the payment promptly next time to avoid expiration *** ### "Session Expired" The checkout link has expired. Your session is tied to the checkout link's validity period (20 minutes), so when the session expires, the checkout link has also expired. **What to do:** * Contact the merchant and request a new payment link * Complete the payment promptly next time to avoid expiration *** ### "MFA Code Invalid" The 6-digit code you entered was incorrect or expired. **What to do:** 1. Wait for your authenticator app to generate a new code (codes change every 30 seconds) 2. Make sure your device's clock is accurate (authenticator apps are time-sensitive) 3. Enter the new code promptly > **Clock sync issue?** On most phones, enable automatic date/time in settings to ensure your authenticator works correctly. *** ### "Transaction Failed" The payment could not be completed due to a network or system error. **What to do:** * Your funds have **not** been deducted * Wait a moment and try again * If the problem persists, contact BLOX support *** ### "Payment Cancelled" You or the system cancelled the checkout. **Common reasons:** * You clicked "Cancel" during the checkout * The checkout link expired (20 minutes) * There was an error during processing **What to do:** * If you still want to pay, ask the merchant for a new link * Check your wallet balance — no funds were deducted *** ## Security Tips ### Before You Pay 1. **Verify the merchant** — Make sure you recognize the merchant name 2. **Check the amount** — Confirm the payment amount matches what you expected 3. **Look for the secure domain** — The URL should start with `https://checkout.blox.my/` ### Protect Your Account 1. **Never share your MFA codes** — BLOX will never ask for your authenticator codes via email, phone, or chat 2. **Don't pay unexpected links** — Be cautious of payment links you didn't expect 3. **Report suspicious activity** — If something seems wrong, contact support@blox.my *** ## Frequently Asked Questions ### Can I cancel a payment after submitting? Once you confirm payment with your MFA code, the transaction is submitted and cannot be cancelled. The funds are transferred directly to the merchant's wallet. ### What if I close the page during payment? * **Before MFA submission:** The checkout remains active until it expires (20 minutes). You can reopen the link and continue. * **After MFA submission:** The payment continues processing. Check your wallet for the transaction status. ### Can I use a different wallet after selecting one? Yes, as long as you haven't completed the MFA step. Go back and select a different wallet. ### What currencies can I pay with? Checkout payments use MYRC (Malaysian Ringgit stablecoin). The merchant receives MYRC on the network they chose when creating the link. ### Are there any fees? Payment fees depend on the merchant's configuration and the blockchain network used. Any fees will be displayed on the checkout page before you confirm. *** ## Need Help? If you encounter issues during checkout: * **Email:** support@blox.my * **Response Time:** Within 24 hours on business days When contacting support, include: * The checkout link (if you have it) * Screenshot of any error messages * Your BLOX account email # Address Book Save wallet addresses you frequently send to. Avoid typing errors and speed up your transfers with a personalized address book. ## Why Use Address Book? * **Prevent mistakes** — No more copy-paste errors with long wallet addresses * **Save time** — Select saved addresses with one tap * **Add labels** — Name each address so you remember who it belongs to * **Multi-network** — Store addresses for Ethereum, Arbitrum, Base, and Solana ## Adding an Address ### Step 1: Open Address Book Navigate to **Address Book** from the sidebar menu. ### Step 2: Tap Add Address Click the **Add** or **+** button to create a new entry. ### Step 3: Enter Details | Field | Description | Example | |-------|-------------|---------| | Name | A nickname for this address | "My MetaMask", "Mom's Wallet" | | Network Type | EVM (Eth/Arb/Base) or SOLANA | EVM | | Address | The full wallet address | `0x1234...abcd` | ### Step 4: Save Tap **Save** to add the address to your book. ## Using Saved Addresses When making a transfer: 1. Tap on the recipient address field 2. Select **Choose from Address Book** 3. Pick the saved address you want to use 4. The address and network are auto-filled ## Managing Your Addresses ### Edit an Address 1. Open **Address Book** 2. Tap the address you want to edit 3. Update the name or other details 4. Tap **Save** > You cannot change the wallet address itself — delete and re-add if the address is wrong. ### Delete an Address 1. Open **Address Book** 2. Tap the address entry 3. Tap **Delete** or the trash icon 4. Confirm deletion Deleting an address does not affect any past transactions to that address. ## Best Practices ### Always Verify New Addresses Before saving a new address: 1. **Send a test transaction** — Transfer a small amount first 2. **Confirm receipt** — Make sure the recipient received it 3. **Then save** — Add to your address book only after verification ### Use Descriptive Labels Good labels help you identify addresses quickly: * "John - ETH Wallet" * "My Ledger - Base" * "DeFi - Uniswap Hot Wallet" Avoid generic names like "Wallet 1" or "Address". ### Keep It Updated Periodically review your address book: * Remove addresses you no longer use * Update labels if ownership changes * Verify addresses are still correct ## Network Compatibility Each saved address is tied to a specific network: | Network Type | Address Format | Example | |---------|----------------|---------| | EVM | 0x + 40 hex characters | `0x742d35Cc6634C0532925a3b844Bc9e7595f...` | | SOLANA | Base58, 32-44 characters | `DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKU...` | > Ethereum, Arbitrum, and Base share the same address format (all are EVM-compatible), but always verify the recipient can receive on your chosen network. ## Common Questions ### Can I import addresses from another wallet? Currently, addresses must be added manually. We're working on import features for future updates. ### Is there a limit to how many addresses I can save? No limit — save as many addresses as you need. ### Are my saved addresses backed up? Yes, your address book is synced to your BLOX account. If you log in on a new device, your saved addresses will be there. ### Can I share my address book with someone? Address books are private to your account and cannot be shared. Each user must maintain their own saved addresses. ## Need Help? Email **support@blox.my** with any questions about your address book or transfers. # Bank Accounts Manage your linked Malaysian bank accounts for MYRC withdrawals. ## Overview The **Bank Accounts** section allows you to link your personal or corporate bank accounts to your BLOX wallet. For safety and regulatory compliance, all withdrawals must be sent to an account registered in your own name (matching your verified MyKad or Passport). ## Managing Bank Accounts You can access your bank accounts directly from the sidebar: 1. Tap **Bank Accounts** in the sidebar menu 2. View your list of currently linked accounts 3. Check account details, including the bank name and account number ## Adding a New Bank Account To link a new bank account: 1. Tap the **Create Bank Account** button 2. **Select Bank**: Choose from the list of supported Malaysian banks (e.g., Maybank, CIMB, Public Bank) 3. **Account Number**: Enter your bank account number without spaces or dashes 4. **Account Holder Name**: This must match the name on your verified BLOX account 5. Tap **Submit** to link the account > \[!IMPORTANT] > You can link multiple bank accounts, but each must be a valid Malaysian bank account in your name. ## Using Bank Accounts Once linked, your bank accounts will be available as options whenever you withdraw MYRC to MYR. ## Common Questions ### Why must the name match? To prevent fraud and comply with Malaysian financial regulations (AML/KYC), we only process withdrawals to bank accounts where the account holder's name matches the verified identity on the BLOX platform. ### How many accounts can I link? There is no strict limit on the number of personal bank accounts you can link, as long as they all belong to you. ### Can I delete a bank account? Yes, you can manage and remove linked bank accounts in the **Bank Accounts** section if you no longer wish to use them. ### What if my bank is not listed? We support all major Malaysian financial institutions. If your bank is missing, please contact support@blox.my. # NFT Rewards Earn exclusive BLOX Beta NFTs as a reward for being an early adopter. These digital collectibles celebrate your membership and unlock special benefits within the BLOX ecosystem. ## BLOX Beta NFT Collection As a BLOX beta user, you can mint exclusive NFTs based on your activity and contributions. There are three tiers, each representing a different level of engagement:

Level 0: Pioneer (Blue)

Early adopter with exclusive benefits. Available to all verified BLOX users during beta.

Level 1: Advocate (Silver)

Community champion with special privileges. Earned through referrals and engagement.

Level 2: Vanguard (Gold)

Elite member with premium access. Reserved for top contributors and power users.

## NFT Tiers | Tier | Name | Requirements | Benefits | |------|------|--------------|----------| | Level 0 | Pioneer (Blue) | Verified BLOX account during beta | Early adopter recognition | | Level 1 | Advocate (Silver) | Active referrals and community participation | Enhanced rewards, priority support | | Level 2 | Vanguard (Gold) | Top-tier engagement and contributions | Premium features, exclusive access | ## How to Check Eligibility 1. Open the BLOX app 2. Navigate to **NFT** or **Rewards** section 3. View your eligibility status for each tier 4. If eligible, the **Mint** button will be active Your eligibility is calculated based on: * Account verification status * Transaction history * Referral activity * Time as a BLOX member ## How to Mint Your NFT ### Step 1: Check Eligibility Open the NFT section and verify you're eligible for the tier you want to mint. ### Step 2: Select NFT Tier Choose which NFT you want to mint. You can only mint tiers you're eligible for. ### Step 3: Confirm Minting Review the details and tap **Mint Now**. The NFT will be minted to your connected Arbitrum wallet address. ### Step 4: View Your NFT Once minted, your NFT appears in: * Your BLOX wallet's NFT section * Any compatible NFT viewer (OpenSea, etc.) * Your connected external wallet ## Where Are NFTs Minted? BLOX Beta NFTs are minted on **Arbitrum**. The NFT is yours to keep, transfer, or display. ## NFT Benefits Holding BLOX Beta NFTs may unlock: * **Priority Access** — Early access to new features * **Reduced Fees** — Lower transaction fees (tier dependent) * **Exclusive Events** — Invitations to BLOX community events * **Airdrops** — Eligibility for future token or NFT airdrops * **Recognition** — Badge display on your BLOX profile > Specific benefits vary by tier and may be updated as the platform evolves. ## Frequently Asked Questions ### Can I mint multiple NFTs? You can mint one NFT per tier you're eligible for. If you qualify for all three tiers, you can mint all three. ### Do I need to pay to mint? Minting may require network gas fees depending on the blockchain. BLOX covers platform fees during the beta period. ### Can I transfer or sell my NFT? Yes, BLOX Beta NFTs are standard NFTs that you own. You can transfer them to other wallets or list them on NFT marketplaces. ### What if I lose my NFT? NFTs are stored on the blockchain, not by BLOX. If you transfer your NFT to another wallet or sell it, you may lose associated benefits. BLOX cannot recover lost or transferred NFTs. ### Will there be more NFT tiers? The current collection is the BLOX Beta series. Future collections may be released as the platform grows. ### How do I prove I hold the NFT for benefits? Connect your wallet containing the NFT to your BLOX account. The platform automatically detects your holdings and applies eligible benefits. ## Need Help? Email **support@blox.my** with questions about: * NFT eligibility * Minting issues * Benefit activation We respond within 24 hours on business days. # Leaderboard & Referrals Track your progress, climb the ranks, and invite friends to join the BLOX community. ## How It Works The Leaderboard ranks users based on their activity and contributions. You can find your ranking and referral tools directly in the **Leaderboard** section in the sidebar. ## Referrals Share BLOX with your friends and grow the community together. ### Your Invite Code 1. Tap **Leaderboard** in the sidebar 2. Find your unique **Invite Code** at the top of the page 3. Click the copy icon to get your referral link: `https://blox.my/signup?invite=YOURCODE` ### Benefits * **Community Growth**: Help more Malaysians access the on-chain economy * **Exclusive Perks**: Early access to new features and potential future rewards * **Bragging Rights**: Climb the leaderboard and earn your spot at the top ## Tracking Your Progress The leaderboard is updated regularly to show: * Current rank * Activity score * Recent community contributions ## Rules * Referrals must be genuine new users * Users must complete eKYC for referrals to count towards certain rewards * BLOX reserves the right to disqualify accounts for fraudulent referral activity ## Need Help? Questions about the leaderboard or your referrals? Email **support@blox.my**. # Getting Started Build MYRC-native applications with BLOX APIs. Integrate Wallet, Onramp, and Payout into your platform. > **Want working code first?** Follow the product guides: [Wallet](/api/wallet-api/overview#step-by-step-guide) · [Onramp](/api/onramp-api/overview#step-by-step-guide) · [Payout](/api/payout-api/overview#step-by-step-guide). ## API products

Wallet API

Transfer MYRC and withdraw fiat programmatically.

Onramp API

Create checkout links for customers to pay and receive MYRC.

Payout API

Send bank payouts from a prefunded balance.

## First call in 5 minutes :::steps ### Get API credentials Contact the BLOX team to enable API key access for your account (sandbox and production). Register a signer public key with your key. ### Authenticate * **API Key** — `blox-api-key` header (or `Authorization: Bearer`) * **Request Signature** — RFC 9421 for POST/PUT/PATCH/DELETE Reads need only the key. See [Request Signing](/api/authentication/signature) when you write. ### Call health ```bash curl "https://api.sandbox.blox.my/v1/health" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.sandbox.blox.my/v1/health", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.sandbox.blox.my/v1/health", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.sandbox.blox.my/v1/health", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.sandbox.blox.my/v1/health")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.sandbox.blox.my/v1/health" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` ::: ```json { "success": true, "message": "API key is valid", "account": { "id": "c741a292-5682-4b75-8f49-ebd87e7785a1", "name": "Your Business Name", "type": "BUSINESS", "status": "ACTIVE" } } ``` Bases and chains: [Environments](/api/environments). ## Typical flows | Product | Flow | |---------|------| | Onramp | Create checkout → customer pays → webhook / status poll | | Wallet | Create transfer or fiat withdrawal → poll it by ID | | Payout | Prefund → create payout → webhook / status poll | See [Integration Flows](/api/guide-on-off-ramp) for Mermaid sequence diagrams and step-by-step pointers. ## Next

Integration Flows

Onramp, Wallet, and Payout sequence diagrams

Authentication

API keys and request signing

Idempotency

Retry a write safely — and when you must not

Webhooks

Shared delivery and signature verification

Errors

HTTP status codes and error bodies

Rate Limits

Sandbox and production quotas

## For LLMs & agents Machine-readable docs for coding agents and chat tools: * /llms.txt — curated index of every page with one-line descriptions * /llms-full.txt — full docs concatenated as markdown * Per-page raw markdown under `/assets/md/`, at the page's path with `.md` appended (e.g. /assets/md/api/getting-started.md), or use **Copy as Markdown** on any page ## Support * **Email**: support@blox.my * **GitHub**: [github.com/Blox-My](https://github.com/Blox-My) # Integration Flows End-to-end sequence diagrams for **Wallet**, **Onramp (Checkout)**, and **Payout**. Jump to a product: * [Wallet: transfer MYRC](#wallet-transfer-myrc) * [Wallet: fiat withdrawal](#wallet-fiat-withdrawal) * [Onramp (Checkout)](#onramp-checkout) * [Payout API](#payout-api) ## Prerequisites * Contact the BLOX team to enable API key access for sandbox and production * Register a signer public key when your API key is created * Use [sandbox](/api/environments) until your flow is verified Auth: `blox-api-key` on every request; RFC 9421 signature on POST/PUT/PATCH/DELETE. See [Authentication](/api/authentication/overview) · [Environments](/api/environments). *** ## Wallet: transfer MYRC Move MYRC from your BLOX wallet to an external address. ```mermaid sequenceDiagram participant PartnerBackend as Partner Backend participant BloxAPI as BLOX API participant Chain as Blockchain PartnerBackend->>BloxAPI: GET /v1/wallet/networks BloxAPI-->>PartnerBackend: Active networks + tokenIds PartnerBackend->>BloxAPI: POST /v1/wallet/token/withdrawals BloxAPI-->>PartnerBackend: Transfer created BloxAPI->>Chain: Broadcast withdrawal Chain-->>BloxAPI: Confirmed PartnerBackend->>BloxAPI: GET /v1/wallet/token/withdrawals/{id} BloxAPI-->>PartnerBackend: Updated status ``` ### Steps 1. Resolve `tokenId` from `GET /v1/wallet/networks`. 2. Call `POST /v1/wallet/token/withdrawals` with `tokenId`, `addressTo`, `amount` (sen), optional `reference`. See [Create Token Transfer](/api/wallet-api/endpoints#create-token-transfer). 3. Monitor with `GET /v1/wallet/token/withdrawals/{id}`; use the transaction list later for reconciliation. Amount range: **1,000–100,000,000** sen (RM 10 – RM 1,000,000). *** ## Wallet: fiat withdrawal Withdraw MYR from your BLOX wallet to a linked bank account. ```mermaid sequenceDiagram participant PartnerBackend as Partner Backend participant BloxAPI as BLOX API participant Bank as Bank rails PartnerBackend->>BloxAPI: GET /v1/wallet/bank-accounts BloxAPI-->>PartnerBackend: Linked bank accounts PartnerBackend->>BloxAPI: POST /v1/wallet/fiat/withdrawals BloxAPI-->>PartnerBackend: Withdrawal created BloxAPI->>Bank: Process payout Bank-->>BloxAPI: Credited (T+1) PartnerBackend->>BloxAPI: GET /v1/wallet/fiat/withdrawals/{id} BloxAPI-->>PartnerBackend: Updated status ``` ### Steps 1. List bank accounts with `GET /v1/wallet/bank-accounts`. 2. Call `POST /v1/wallet/fiat/withdrawals` with `bankAccountId`, `amount`, optional `reference`. See [Create Fiat Withdrawal](/api/wallet-api/endpoints#create-fiat-withdrawal). 3. Monitor with `GET /v1/wallet/fiat/withdrawals/{id}`; use the transaction list later for reconciliation. Amount range: **1,000–100,000,000** sen (RM 10 – RM 1,000,000). *** ## Onramp (Checkout) Collect MYR via a BLOX checkout link and deliver MYRC to a destination address. ```mermaid sequenceDiagram participant User participant PartnerApp as Partner App participant PartnerBackend as Partner Backend participant BloxAPI as BLOX API participant BloxUI as BLOX Checkout UI User->>PartnerApp: Start payment / mint PartnerApp->>PartnerBackend: Create checkout PartnerBackend->>BloxAPI: POST /v1/checkout BloxAPI-->>PartnerBackend: checkoutUrl, checkout id PartnerBackend-->>PartnerApp: checkoutUrl PartnerApp->>BloxUI: Open checkoutUrl User->>BloxUI: Complete payment BloxAPI->>PartnerBackend: Webhook (status update) BloxUI-->>PartnerApp: Redirect to redirectUrl PartnerApp-->>User: Show success ``` ### Steps 1. **Create checkout** — `POST /v1/checkout` with `type`, `addressTo`, `amount` (sen, RM10–RM1M), `tokenId`, `redirectUrl`, `title`, optional `description`. See [Create checkout](/api/onramp-api/endpoints#create-checkout). `type` is required: `BLOX_ACCOUNT` (no fee), `FPX_HOSTED` (add `feeMode`; the buyer picks their bank on the BLOX page), or `FPX_DIRECT` (add `feeMode`, `buyerName`, `bank`, `bankType`). 2. **Send the customer** to `checkoutUrl` from the response. 3. **Track status** — poll `GET /v1/checkout/:id` and/or handle the per-request [checkout webhook](/api/onramp-api/webhooks). Amount range: **1,000–100,000,000** sen (RM 10 – RM 1,000,000). *** ## Payout API Send bank payouts from a prefunded balance, on the same `/v1` surface as Wallet and Onramp. ```mermaid sequenceDiagram participant PartnerBackend as Partner Backend participant BloxAPI as BLOX Payout API participant Bank as Bank rails PartnerBackend->>BloxAPI: GET /v1/payout/prefund/balance BloxAPI-->>PartnerBackend: available, inFlight, receivable PartnerBackend->>BloxAPI: POST /v1/payouts (Idempotency-Key, feeMode) BloxAPI-->>PartnerBackend: Payout INITIATED BloxAPI->>Bank: Submit transfer Bank-->>BloxAPI: Delivered, or rejected BloxAPI->>PartnerBackend: Webhook payout.updated PartnerBackend->>BloxAPI: GET /v1/payouts/:id BloxAPI-->>PartnerBackend: Final status ``` ### Steps 1. Confirm prefund with `GET /v1/payout/prefund/balance`. 2. Create a payout with `POST /v1/payouts` (`amount` RM1–RM100k, `feeMode`, an `Idempotency-Key` header, exactly one of `beneficiaryId` or inline `beneficiary`). See [Create Payout](/api/payout-api/endpoints#create-payout). 3. Handle [payout webhooks](/api/payout-api/webhooks) and/or poll `GET /v1/payouts/:id`. Amount range: **100–10,000,000** sen (RM 1 – RM 100,000). *** ## Next steps * [Getting Started](/api/getting-started) * [Wallet API](/api/wallet-api/overview) * [Onramp API](/api/onramp-api/overview) * [Payout API](/api/payout-api/overview) * [Errors](/api/errors) * [Environments & Chains](/api/environments) # Authentication All BLOX merchant API requests require authentication. This section covers how to authenticate your API requests securely. Wallet, Onramp (Checkout), and **Payout** share the same wire format. Payout has a slightly different freshness / replay policy — see [Payout differences](/api/authentication/signature#payout-differences). ## Overview BLOX uses a two-layer authentication approach: 1. **API Key** - Identifies your application and grants access to the API. 2. **Request Signature** - Protects state-changing requests (POST, PUT, etc.) from tampering using **RFC 9421 HTTP Message Signatures**. ## Authentication Methods

API Keys

Learn how to obtain and use your `blox-api-key` for authentication.

Request Signing

Secure your state-changing requests with digital signatures (RFC 9421).

## Quick Reference ### Required Headers | Header | Required | Description | |--------|----------|-------------| | `blox-api-key` | Always | Your API key (secret) | | `Content-Type` | With Body | Set to `application/json` | | `Signature` | State-changing | RFC 9421 cryptographic signature | | `Signature-Input` | State-changing | Metadata for the signature | | `Content-Digest` | With Body | RFC 9421 digest of the request body | ### Example Request (Read-only) ```bash curl "https://api.blox.my/v1/health" \ -H "blox-api-key: YOUR_API_KEY" ``` ## Next Steps 1. [Get API Keys](/api/authentication/api-keys) — contact the BLOX team to enable access. 2. [Learn request signing](/api/authentication/signature) for secure API calls. 3. [Test in Sandbox](/api/environments) before going live. # API Keys Contact the BLOX team to enable API key access for your account (sandbox and production). API keys authenticate your requests; each key is tied to your account and a registered signer public key. > \[!IMPORTANT] > When your API key is created, you **must** provide a **signing public key**. It is used to verify signatures on all state-changing requests (POST, PUT, PATCH, DELETE). ### Key facts | Fact | Detail | |------|--------| | Format | Keys start with `blox_pk_` | | Display | The secret is shown **once** at creation — store it securely | | Active keys | Up to **5** active keys per account | | Signer algorithms | `ed25519`, `ecdsa-p256-sha256`, `ecdsa-secp256k1-sha256` | ### Environments * **Sandbox keys** — for development and testing against `api.sandbox.blox.my` * **Production keys** — for live traffic against `api.blox.my` Contact the BLOX team for both. ## Authentication Methods ### Header Authentication Prefer the `blox-api-key` header: ```bash curl https://api.sandbox.blox.my/v1/health \ -H "blox-api-key: YOUR_API_KEY" ``` `Authorization: Bearer YOUR_API_KEY` is also accepted. ## Request Headers | Header | Required | Description | |--------|----------|-------------| | `blox-api-key` | Yes (or Bearer) | Your API key secret | | `Content-Type` | Yes (for JSON bodies) | `application/json` | | `Content-Digest` | Yes (signed requests with body) | `sha-256=:BASE64:` of canonicalized body | | `Signature-Input` | Yes (POST/PUT/PATCH/DELETE) | RFC 9421 signature metadata | | `Signature` | Yes (POST/PUT/PATCH/DELETE) | RFC 9421 signature | GET requests need only the API key. Write methods require a signature — see [Request Signature](./signature). ## Testing Authentication ```bash curl https://api.sandbox.blox.my/v1/health \ -H "blox-api-key: YOUR_API_KEY" ``` ```json { "success": true, "message": "API key is valid", "account": { "id": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416", "name": "Your Business Name", "type": "BUSINESS", "status": "ACTIVE" } } ``` # Request Signing For all state-changing requests (**POST**, **PUT**, **PATCH**, **DELETE**), you must include a digital signature. BLOX uses **RFC 9421 HTTP Message Signatures** to ensure the integrity, authenticity, and non-repudiation of requests. ## How it Works RFC 9421 signatures use **asymmetric cryptography**. You sign requests with your **Private Key**, and BLOX verifies them using the **Signing Public Key** you provided when generating your API key. | Aspect | Description | |--------|-------------| | **Key Type** | Public/Private key pair | | **Security** | Private key never leaves your server | | **Algorithm** | Ed25519 / ECDSA | To sign a request, you must: 1. Canonicalize and hash the request body to generate a **Content-Digest**. 2. Construct a **Signature Base String** containing the request metadata (method, path) and headers. 3. Sign the base string using your private key. 4. Send the signature and metadata in the `Signature` and `Signature-Input` headers. *** ## Key Pair Generation Before making signed requests, generate a cryptographic key pair and register the public key when creating your API key. ### Supported Algorithms | Algorithm | Identifier | Key Format | What gets signed | Best For | |-----------|------------|------------|------------------|----------| | Ed25519 | `ed25519` | PEM | The signature base string | Recommended for most integrations | | ECDSA P-256 | `ecdsa-p256-sha256` | PEM | The signature base string | Standard ECDSA | | ECDSA secp256k1 | `ecdsa-secp256k1-sha256` | EVM address | An **EIP-712 typed message** — [different, see below](#ecdsa-secp256k1-evm-wallet) | Signing with an Ethereum wallet | The first two sign the same bytes and share the helper below. secp256k1 is a genuinely different scheme — read its section before you pick it. ### Generating Ed25519 Keys (Recommended) ```bash # Generate private key openssl genpkey -algorithm Ed25519 -out private_key.pem # Extract public key openssl pkey -in private_key.pem -pubout -out public_key.pem ``` ### Generating ECDSA P-256 Keys ```bash # Generate private key openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem # Extract public key openssl ec -in private_key.pem -pubout -out public_key.pem ``` ### Using an Ethereum Wallet (secp256k1) If you prefer signing with an EVM wallet, provide your Ethereum address as the public key. Signatures are verified using public key recovery from the signature. Your private key must never be shared or uploaded. Keep it secure on your server. *** ## Required Headers All signed requests require these headers in addition to `blox-api-key`: | Header | Format | Description | |--------|--------|-------------| | `Content-Digest` | `sha-256=:BASE64:` | Base64-encoded SHA-256 hash of the canonicalized request body. Omit it on a request that sends no body. | | `Signature-Input` | `sig1=(...);created=...;keyid=...;alg=...` | Metadata describing the signature components. | | `Signature` | `sig1=:BASE64:` | The cryptographic signature of the base string. | ### Content-Digest Compute SHA-256 hash of the canonicalized body (keys sorted alphabetically) and Base64-encode it: ```http Content-Digest: sha-256=:X48E9qOokqqrvDts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: ``` ### Signature-Input Defines which components are signed. Required components: `@method`, `@path`, `content-digest`, `content-type`. A request that sends no body signs `@method` and `@path` only — the endpoints that take no body say so. ```http Signature-Input: sig1=(@method @path content-digest content-type);created=1705900000;keyid="your_key_id";alg="ed25519" ``` ### Signature The resulting signature, wrapped in colons: ```http Signature: sig1=:w7SdqL8L...: ``` *** ## Signature Base String The signature base string is a deterministic representation of the request: ```text "@method": POST "@path": /v1/wallet/token/withdrawals "content-digest": sha-256=:X48E9qOokqqrvDts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: "content-type": application/json "@signature-params": (@method @path content-digest content-type);created=1705900000;keyid="your_key_id";alg="ed25519" ``` *** ## Implementation Examples The examples use Ed25519 and RFC 8785 JSON canonicalization. Set `BLOX_KEY_ID` and `BLOX_PRIVATE_KEY` (the path to your PKCS#8 PEM private key), then reuse the helper on endpoint pages. ```bash # Generate these headers with one of the language helpers in this tab. # Sign the pathname only, not the host or query string. curl -X POST "https://api.sandbox.blox.my/v1/echo" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"message":"hello"}' ``` ```javascript // npm install canonicalize import { createHash, sign } from "node:crypto"; import { readFileSync } from "node:fs"; import canonicalize from "canonicalize"; export function signBlox(method, path, body) { const canonicalBody = canonicalize(body); const digest = createHash("sha256") .update(canonicalBody) .digest("base64"); const contentDigest = `sha-256=:${digest}:`; const created = Math.floor(Date.now() / 1000); const keyId = process.env.BLOX_KEY_ID; const params = `(@method @path content-digest content-type);created=${created};` + `keyid="${keyId}";alg="ed25519"`; const base = [ `"@method": ${method.toUpperCase()}`, `"@path": ${path}`, `"content-digest": ${contentDigest}`, '"content-type": application/json', `"@signature-params": ${params}`, ].join("\n"); const signature = sign( null, Buffer.from(base), readFileSync(process.env.BLOX_PRIVATE_KEY), ); return { "Content-Digest": contentDigest, "Signature-Input": `sig1=${params}`, Signature: `sig1=:${signature.toString("base64")}:`, }; } ``` ```python # pip install rfc8785 cryptography import base64 import hashlib import os import time import rfc8785 from cryptography.hazmat.primitives.serialization import load_pem_private_key def sign_blox(method, path, body): canonical = rfc8785.dumps(body) digest = base64.b64encode( hashlib.sha256(canonical).digest() ).decode() content_digest = f"sha-256=:{digest}:" created = int(time.time()) params = ( "(@method @path content-digest content-type);" f'created={created};keyid="{os.environ["BLOX_KEY_ID"]}";' 'alg="ed25519"' ) base = "\n".join([ f'"@method": {method.upper()}', f'"@path": {path}', f'"content-digest": {content_digest}', '"content-type": application/json', f'"@signature-params": {params}', ]) with open(os.environ["BLOX_PRIVATE_KEY"], "rb") as key_file: key = load_pem_private_key(key_file.read(), password=None) signature = base64.b64encode(key.sign(base.encode())).decode() return { "Content-Digest": content_digest, "Signature-Input": f"sig1={params}", "Signature": f"sig1=:{signature}:", } ``` ```go // go get github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer func signBlox(method, path string, body any) (http.Header, error) { raw, _ := json.Marshal(body) canonical, err := jsoncanonicalizer.Transform(raw) if err != nil { return nil, err } sum := sha256.Sum256(canonical) digest := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" created := time.Now().Unix() params := fmt.Sprintf( "(@method @path content-digest content-type);created=%d;"+ "keyid=\"%s\";alg=\"ed25519\"", created, os.Getenv("BLOX_KEY_ID"), ) base := fmt.Sprintf( "\"@method\": %s\n\"@path\": %s\n\"content-digest\": %s\n"+ "\"content-type\": application/json\n"+ "\"@signature-params\": %s", strings.ToUpper(method), path, digest, params, ) pemBytes, _ := os.ReadFile(os.Getenv("BLOX_PRIVATE_KEY")) block, _ := pem.Decode(pemBytes) key, _ := x509.ParsePKCS8PrivateKey(block.Bytes) sig := ed25519.Sign(key.(ed25519.PrivateKey), []byte(base)) headers := make(http.Header) headers.Set("Content-Digest", digest) headers.Set("Signature-Input", "sig1="+params) headers.Set( "Signature", "sig1=:"+base64.StdEncoding.EncodeToString(sig)+":", ) return headers, nil } ``` ```java // com.github.erdtman:java-json-canonicalization static Map signBlox( String method, String path, String json ) throws Exception { byte[] canonical = new JsonCanonicalizer(json).getEncodedUTF8(); byte[] hash = MessageDigest.getInstance("SHA-256") .digest(canonical); String digest = "sha-256=:" + Base64.getEncoder().encodeToString(hash) + ":"; long created = Instant.now().getEpochSecond(); String params = "(@method @path content-digest content-type);created=" + created + ";keyid=\"" + System.getenv("BLOX_KEY_ID") + "\";alg=\"ed25519\""; String base = "\"@method\": " + method.toUpperCase() + "\n\"@path\": " + path + "\n\"content-digest\": " + digest + "\n\"content-type\": application/json" + "\n\"@signature-params\": " + params; String pem = Files .readString(Path.of(System.getenv("BLOX_PRIVATE_KEY"))) .replaceAll("-----[^-]+-----", "") .replaceAll("\\s", ""); PrivateKey key = KeyFactory.getInstance("Ed25519").generatePrivate( new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem)) ); var signer = java.security.Signature.getInstance("Ed25519"); signer.initSign(key); signer.update(base.getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(signer.sign()); return Map.of( "Content-Digest", digest, "Signature-Input", "sig1=" + params, "Signature", "sig1=:" + signature + ":" ); } ``` ```csharp // NuGet: JsonCanonicalizer, NSec.Cryptography static Dictionary SignBlox( string method, string path, string json, Key privateKey ) { var canonical = new JsonCanonicalizer(json).GetEncodedUTF8(); var digestBytes = SHA256.HashData(canonical); var digest = $"sha-256=:{Convert.ToBase64String(digestBytes)}:"; var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var keyId = Environment.GetEnvironmentVariable("BLOX_KEY_ID"); var parameters = "(@method @path content-digest content-type);" + $"created={created};keyid=\"{keyId}\";alg=\"ed25519\""; var signatureBase = $"\"@method\": {method.ToUpperInvariant()}\n" + $"\"@path\": {path}\n" + $"\"content-digest\": {digest}\n" + "\"content-type\": application/json\n" + $"\"@signature-params\": {parameters}"; var signature = SignatureAlgorithm.Ed25519.Sign( privateKey, Encoding.UTF8.GetBytes(signatureBase) ); return new() { ["Content-Digest"] = digest, ["Signature-Input"] = $"sig1={parameters}", ["Signature"] = $"sig1=:{Convert.ToBase64String(signature)}:" }; } ``` *** ## Algorithm-Specific Notes ### Ed25519 * Sign the signature base directly (no prehashing required) * Recommended for simplicity and security * Use `alg="ed25519"` in Signature-Input ### ECDSA P-256 * Sign using SHA-256 as the hash function * Use `alg="ecdsa-p256-sha256"` in Signature-Input ### ECDSA secp256k1 (EVM Wallet) secp256k1 does **not** sign the signature base string. It signs an **EIP-712 typed message** built from the same components. Signing the base string the way Ed25519 and P-256 do will always fail. * The `keyid` is your Ethereum address (e.g. `0x742d35Cc...`), and it is also a field inside the signed message * Use `alg="ecdsa-secp256k1-sha256"` in `Signature-Input` * The `Signature` header carries **base64 of the raw 65-byte `r || s || v`**, not hex * Verified by `ecrecover`: the recovered address must equal your registered one The typed data is exactly: ```javascript const domain = { name: "Blox API", version: "1" }; const types = { HttpRequest: [ { name: "method", type: "string" }, { name: "path", type: "string" }, { name: "contentDigest", type: "string" }, { name: "contentType", type: "string" }, { name: "created", type: "uint256" }, { name: "keyid", type: "address" }, ], }; ``` Note the domain has **no** `chainId` and **no** `verifyingContract` — just the two fields above. Complete signer, using [viem](https://viem.sh): ```javascript // npm install viem import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { privateKeyToAccount } from "viem/accounts"; const wallet = privateKeyToAccount(process.env.BLOX_EVM_PRIVATE_KEY); const domain = { name: "Blox API", version: "1" }; const types = { HttpRequest: [ { name: "method", type: "string" }, { name: "path", type: "string" }, { name: "contentDigest", type: "string" }, { name: "contentType", type: "string" }, { name: "created", type: "uint256" }, { name: "keyid", type: "address" }, ], }; export async function signBloxEvm(method, path, body) { const digest = createHash("sha256") .update(canonicalize(body)) .digest("base64"); const contentDigest = `sha-256=:${digest}:`; const created = Math.floor(Date.now() / 1000); const signatureHex = await wallet.signTypedData({ domain, types, primaryType: "HttpRequest", message: { method: method.toUpperCase(), path, contentDigest, contentType: "application/json", created: BigInt(created), // Your address is both the keyid and a signed field. keyid: wallet.address, }, }); // 65 raw bytes (r || s || v), base64 — not the 0x hex string. const signature = Buffer.from(signatureHex.slice(2), "hex").toString( "base64", ); const params = `(@method @path content-digest content-type);created=${created};` + `keyid="${wallet.address}";alg="ecdsa-secp256k1-sha256"`; return { "Content-Digest": contentDigest, "Signature-Input": `sig1=${params}`, Signature: `sig1=:${signature}:`, }; } ``` `Signature-Input` still lists `(@method @path content-digest content-type)` and must agree with the typed message — the server rebuilds the message from those same headers before recovering your address. *** ## Troubleshooting | Error | Cause | Solution | |-------|-------|----------| | `Invalid signature` | Signature verification failed | Verify signature base construction matches exactly | | `Missing Signature-Input header` | Header not provided | Include Signature-Input header | | `Missing Content-Digest header for request with body` | Body hash not provided | Include Content-Digest whenever you send a body | | `Clock drift detected` | `created` outside the allowed window | Wallet / Checkout: within 30s past / 5s future. Payout: within ±300s | | `Signature has already been used` | The same signature arrived twice | Re-sign with a fresh `created` on every attempt, retries included | ### Common Issues 1. **Key ordering matters** — Ensure JSON is canonicalized (keys sorted alphabetically) before hashing 2. **Clock skew** — Keep `created` inside the product window (see table above) 3. **Encoding** — Use UTF-8 for all string operations 4. **Line endings** — Use `\n` (LF) not `\r\n` (CRLF) in the signature base 5. **Replay protection** — Each signature can only be used once, on every surface. Re-sign on every retry, and reuse the `Idempotency-Key` where the endpoint takes one *** ## Payout differences Payout uses the same wire format but a wider timestamp window. Sign every retry fresh, and reuse the same `Idempotency-Key` when retrying a create request. Wire format is identical to Wallet / Onramp. What differs is the freshness policy, and it is a property of the **route**, not the path prefix — everything lives under `/v1`: | Policy | Wallet / Onramp / Checkout | Payout | |--------|----------------------------|--------| | Routes | everything else under `/v1`, including `/v1/wallet/*` | `/v1/payouts/*` and `/v1/payout/prefund/balance` | | `created=` window | 30s past / 5s future | ±300 seconds | | Auth-layer signature-once | Yes | Yes | Deposit trigger addresses exist on both surfaces and follow the surface they sit on: [`/v1/wallet/bank-accounts/{bankAccountId}/address`](/api/wallet-api/endpoints#create-a-deposit-trigger-address) uses the tight window, [`/v1/payouts/beneficiaries/{beneficiaryId}/address`](/api/payout-api/endpoints#create-a-deposit-trigger-address) uses the payout one. If you sign requests for both, use a fresh `created` per request. A signature is accepted once on either surface, so a retry means re-signing with a fresh `created`. That is not the same control as `Idempotency-Key`: re-signing gets the request past the auth layer, and the key is what stops the second one from creating a second payout. Webhook delivery signing remains HMAC (not RFC 9421) — see [Webhooks](/api/webhooks). *** ## Security Best Practices 1. **Never share your private key** — It should only exist on your server 2. **Rotate keys periodically** — Create new keys and deactivate old ones 3. **Use environment variables** — Don't hardcode keys in source code 4. **Monitor API key usage** — Check for unusual activity in the dashboard # Environments & Supported Chains This page covers API base URLs and the blockchain networks returned by `GET /v1/wallet/networks` (active networks and active MYRC tokens only). Always prefer that endpoint over hard-coding IDs. ## API Environments | Environment | Base URL | |-------------|----------| | Sandbox | `https://api.sandbox.blox.my` | | Production | `https://api.blox.my` | Hostnames: production `https://api.blox.my`, sandbox `https://api.sandbox.blox.my`. There is **one** merchant surface — Wallet, Onramp and Payout all live under `/v1`. ### Quick health check ```bash curl "https://api.sandbox.blox.my/v1/health" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```json { "success": true, "message": "API key is valid", "account": { "id": "…", "name": "Your Business Name", "type": "BUSINESS", "status": "ACTIVE" } } ``` > Tip: Never commit production keys. Use a secret manager or CI/CD secrets. ## Supported Blockchain Networks The following blockchain networks are supported for MYRC transactions: ### Production #### Ethereum Mainnet * **Chain ID**: 1 * **Network Name**: Ethereum * **MYRC Contract Address**: `0xbed7D999f1D71Ac70c263F64c7c7E009d691be2e` * **Explorer**: [Etherscan](https://etherscan.io/address/0xbed7D999f1D71Ac70c263F64c7c7E009d691be2e) #### Arbitrum One * **Chain ID**: 42161 * **Network Name**: Arbitrum One * **MYRC Contract Address**: `0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e` * **Explorer**: [Arbiscan](https://arbiscan.io/address/0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e) #### Base * **Chain ID**: 8453 * **Network Name**: Base * **MYRC Contract Address**: `0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e` * **Explorer**: [Basescan](https://basescan.org/address/0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e) #### Solana Mainnet * **Network Name**: Solana * **MYRC Token Mint**: `myrcAs6bpP2g5oGHZ3qpgrfZQAFkbo9KUHdqYDXMjGv` * **Explorer**: [Solscan](https://solscan.io/token/myrcAs6bpP2g5oGHZ3qpgrfZQAFkbo9KUHdqYDXMjGv) ### Sandbox #### Ethereum Sepolia * **Chain ID**: 11155111 * **Network Name**: Sepolia * **MYRC Contract Address**: `0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e` * **Explorer**: [Sepolia Etherscan](https://sepolia.etherscan.io/address/0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e) #### Arbitrum Sepolia * **Chain ID**: 421614 * **Network Name**: Arbitrum Sepolia * **MYRC Contract Address**: `0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e` * **Explorer**: [Arbitrum Sepolia Explorer](https://sepolia.arbiscan.io/address/0x3eD03E95DD894235090B3d4A49E0C3239EDcE59e) #### Solana Devnet * **Network Name**: Solana Devnet * **MYRC Token Mint**: `myrcAs6bpP2g5oGHZ3qpgrfZQAFkbo9KUHdqYDXMjGv` * **Explorer**: [Solscan Devnet](https://solscan.io/token/myrcAs6bpP2g5oGHZ3qpgrfZQAFkbo9KUHdqYDXMjGv?cluster=devnet) ## Important Notes * Ethereum and Arbitrum/Base MYRC use the ERC-20 interface; Solana MYRC uses SPL * Contract source is available on the explorers linked above * Do not hard-code `tokenId` values across environments — fetch them from the API # Error Responses BLOX APIs use standard HTTP status codes and return JSON error bodies. ## Error Format Every error on `/v1` and `/checkout` has the same three fields: ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ``` | Field | Type | Description | |-------|------|-------------| | `code` | string | Stable `SCREAMING_SNAKE_CASE` identifier. **This is the contract** — branch on it | | `message` | string | For humans. May be reworded at any time; never parse it | | `requestId` | string (uuid) | Identifies the request in BLOX logs. Quote it when reporting a `5xx` | Adding a code is not a breaking change. Changing or removing one is. ### Codes | Status | Codes | |--------|-------| | `400` | `INVALID_REQUEST`, `VALIDATION_FAILED`, `CONFLICT`, `INSUFFICIENT_BALANCE`, `LIMIT_EXCEEDED`, `UNKNOWN_BENEFICIARY` | | `401` | `UNAUTHORIZED` | | `403` | `FORBIDDEN`, `FEATURE_DISABLED`, `LIMIT_EXCEEDED`, `ACCOUNT_TERMINATED`, `ACCOUNT_FROZEN`, `ACCOUNT_SUSPENDED`, `ACCOUNT_PENDING_VERIFICATION`, `ACCOUNT_NOT_ACTIVE` | | `404` | `NOT_FOUND` | | `422` | `VALIDATION_FAILED` | | `429` | `RATE_LIMITED` | | `503` | `SERVICE_UNAVAILABLE` | | `5xx` | `INTERNAL_ERROR` | `LIMIT_EXCEEDED` comes back as `400` when an amount would breach a daily or monthly limit, and as `403` when a count is full — beneficiaries on the account, or allowed senders on a trigger address. A `5xx` never explains itself: the body is `INTERNAL_ERROR` with a fixed message and the detail goes to our logs under `requestId`. The exception is a deliberate `503 SERVICE_UNAVAILABLE`, which means "retry later, nothing happened" and keeps its own message. *** ## Authentication errors ### 401 Unauthorized Everything below returns `code: "UNAUTHORIZED"`. The `message` narrows it down while you are debugging, but it is prose — never branch on it. | Cause | Fix | |-------|-----| | API key missing, unknown, or inactive | Send `blox-api-key` (or `Authorization: Bearer`) with an active key | | A write is missing `Signature`, `Signature-Input`, or `Content-Digest` | Sign every POST / PUT / PATCH / DELETE — see [Request Signing](/api/authentication/signature) | | Signature or digest does not match | Re-sign over the exact bytes you send. Serializing the body twice is the usual cause | | Signature algorithm differs from your registered signer | Sign with the key you registered | | Clock drift | Keep `created` inside the window below, and keep your server clock synchronized | | Signature reused | Sign each request fresh — a signature is accepted once | ```json { "code": "UNAUTHORIZED", "message": "…", "requestId": "…" } ``` Freshness windows: | Surface | `created` must be within | Reuse | |---------|-------------------------|-------| | `/v1` (Wallet, Onramp, Checkout) | 30s past, 5s future | Rejected — sign each request fresh | | Payout routes (`/v1/payouts*`, `/v1/payout/prefund/balance`) | ±300s | Rejected — sign each request fresh | ### 403 Forbidden | `code` | When | |--------|------| | `ACCOUNT_TERMINATED` | Account terminated | | `ACCOUNT_FROZEN` | Account frozen | | `ACCOUNT_PENDING_VERIFICATION` | Account pending verification | | `ACCOUNT_SUSPENDED` | Account suspended | | `ACCOUNT_NOT_ACTIVE` | Account inactive or closed | | `FEATURE_DISABLED` | Required product feature not enabled for the account | `ACCOUNT_TERMINATED` blocks every route, reads included. The others block money-moving routes only, so reads keep working — except `ACCOUNT_SUSPENDED`, which still permits money out but not money in. Contact the BLOX team to enable API products (e.g. payout) for your account. *** ## Other common status codes | Status | Meaning | |--------|---------| | `202` | Not an error — the first request with your `Idempotency-Key` is still running. Retry with the same key ([Idempotency](/api/idempotency)) | | `400` | Validation or business-rule failure | | `404` | Resource not found | | `422` | An `Idempotency-Key` was reused with a different body | | `429` | Rate limit exceeded (see [Rate Limits](/api/rate-limits)) | | `500` | Unexpected server error | Schema validation failures come back as `VALIDATION_FAILED`, with the offending fields joined into `message`. *** ## Handling errors ```bash # Inspect status and body; retry 429 with backoff curl -sS -w "\nHTTP %{http_code}\n" "$URL" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch(url, options); if (!response.ok) { const { code, message, requestId } = await response.json(); // Branch on `code`. Log `requestId` — it is what BLOX support looks up. console.error(`${response.status} ${code}: ${message} (${requestId})`); } ``` ```python response = requests.request(method, url, headers=headers, json=body, timeout=10) if not response.ok: payload = response.json() # Branch on `code`. Log `requestId` — it is what BLOX support looks up. print( f"{response.status_code} {payload['code']}: " f"{payload['message']} ({payload['requestId']})" ) ``` ```go response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() if response.StatusCode >= 400 { var body struct { Code string `json:"code"` Message string `json:"message"` RequestID string `json:"requestId"` } json.NewDecoder(response.Body).Decode(&body) // Branch on Code. Log RequestID — it is what BLOX support looks up. log.Printf("%d %s: %s (%s)", response.StatusCode, body.Code, body.Message, body.RequestID) } ``` ```java var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); if (response.statusCode() >= 400) { var body = new ObjectMapper().readTree(response.body()); // Branch on "code". Log "requestId" — it is what BLOX support looks up. System.err.printf("%d %s: %s (%s)%n", response.statusCode(), body.get("code").asText(), body.get("message").asText(), body.get("requestId").asText()); } ``` ```csharp var response = await client.SendAsync(request); if (!response.IsSuccessStatusCode) { using var doc = JsonDocument.Parse( await response.Content.ReadAsStringAsync() ); var root = doc.RootElement; // Branch on "code". Log "requestId" — it is what BLOX support looks up. Console.Error.WriteLine( $"{(int)response.StatusCode} " + $"{root.GetProperty("code").GetString()}: " + $"{root.GetProperty("message").GetString()} " + $"({root.GetProperty("requestId").GetString()})" ); } ``` For `429`, wait and retry with exponential backoff. Do not rely on rate-limit response headers. *** ## Testing ```bash # Missing / invalid API key curl https://api.sandbox.blox.my/v1/health \ -H "blox-api-key: invalid_key" # POST without signature headers (expect 401) curl -X POST https://api.sandbox.blox.my/v1/payouts/beneficiaries \ -H "blox-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"TEST","bankCode":"MBBEMYKL","accountNumber":"1234567890"}' ``` ## Support * **Email**: support@blox.my * **GitHub**: [github.com/Blox-My](https://github.com/Blox-My) # Idempotency The BLOX endpoints that create a payout, a transfer, a withdrawal, a checkout, or a beneficiary take an `Idempotency-Key` header. It exists so that a request you never saw the answer to — a timeout, a dropped connection, a crashed process — can be retried without moving money twice. This page is the shared contract. Endpoint pages link here rather than repeat it. ## The header | | | |---|---| | **Name** | `Idempotency-Key` | | **Format** | A **UUID v4**, validated strictly — the version and variant nibbles must be right | | **Required on** | `POST /v1/payouts`, `POST /v1/payouts/beneficiaries`, `POST /v1/wallet/token/withdrawals`, `POST /v1/wallet/fiat/withdrawals`, `POST /v1/checkout` | | **Missing** | `400` — `Missing idempotency key` | | **Malformed** | `400` — `Invalid idempotency key` | Generate the key and **persist it before you send the request**, next to the order or transfer it belongs to. A key generated in memory is lost by the crash you most need it for. ```javascript import { randomUUID } from "node:crypto"; // Write this to your database BEFORE the API call, not after. const idempotencyKey = randomUUID(); await db.orders.update(orderId, { payoutIdempotencyKey: idempotencyKey }); ``` ## What a key is scoped to A key identifies one request, not one value. The stored record is keyed on your **account** plus the key, and it remembers a hash of the **method, path, and canonical body**. * The same key on a different endpoint is a different record — keys do not collide across routes. * The same key with a different body on the same route is `422`. A key is bound to the exact request it first saw. * Two accounts can use the same key without seeing each other's responses. ## Response statuses | Status | Meaning | What to do | |--------|---------|------------| | `200` | Done. Either your request just completed, or this is a replay of the original response | Use the body. Creates return `200`, never `201` | | `202` | The first request with this key is **still running** | Wait, then retry with the **same** key | | `400` | Key missing or not a UUID v4 | Fix the header | | `422` | This key was already used with a **different** body | You reused a key by mistake. Look up what the key already created | A `202` body is `{ "message": "This request is currently processing." }` — there is no resource in it. Poll with the same key until you get a `200`. If you fire two identical requests at once, the second one does not fail. It waits for the first to finish — up to 30 seconds, backing off from 100ms to 1s — and then returns the same `200`. Only if the first is still running after that do you get a `202`. ## How long a key is remembered **24 hours.** Within that window the same key always returns the original outcome. After it, the record is gone and the same key is treated as new — which is why keys should be fresh per logical operation, not recycled. Client errors are remembered too. A `400` or `422` replays as the same rejection for the full 24 hours, so fix the request and use a **new** key. ## When a request fails If you never got an answer — a timeout, a dropped connection, a server error — **retry with the same key**. Money-moving endpoints will not act twice on one key: | Endpoint | Retry with the same key | |----------|-------------------------| | `POST /v1/payouts` | Returns the original payout. Never sends a second transfer | | `POST /v1/wallet/token/withdrawals` | Returns the original transfer | | `POST /v1/wallet/fiat/withdrawals` | Returns the original withdrawal | | `POST /v1/payouts/beneficiaries` | Returns the existing beneficiary | **Checkout is the exception.** `POST /v1/checkout` can create a second link on a retry. No money moves until a buyer pays one of them, but check first — and under `CHARGE_TO_PREFUND` each link reserves its own fee out of your checkout prefund, so a duplicate ties up money until it expires. Put your order number in the checkout `title` — it is the merchant-supplied handle the list echoes back, so you can see whether your request already landed: ```javascript const existing = await fetch( "https://api.blox.my/v1/checkout", { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ).then((response) => response.json()); const alreadyLanded = existing.data.some( (checkout) => checkout.title === "Order ORDER-2026-0001", ); if (!alreadyLanded) { // Safe to retry now. } ``` ## A rule of thumb * No answer at all → same key, retry. * `4xx` → fix the request, use a new key. * `429` → same key, after a backoff. The request never ran. * `202` → same key, after a short wait. ## Related * [Payout — Create](/api/payout-api/endpoints#create-payout) * [Wallet — Create Token Transfer](/api/wallet-api/endpoints#create-token-transfer) * [Wallet — Create Fiat Withdrawal](/api/wallet-api/endpoints#create-fiat-withdrawal) * [Errors](/api/errors) # Webhooks BLOX delivers events as HTTP POST requests to a URL you register. Every product uses the same envelope, the same signature scheme, and the same delivery policy — learn it once. Product pages list only **which events fire** and **what is in `data`**. ## Register an endpoint Register one endpoint per event type from the dashboard, **Devtools → Webhooks**. | Type | Events | |------|--------| | `CHECKOUT` | [Checkout events](/api/onramp-api/webhooks) | | `PAYOUT` | [Payout events](/api/payout-api/webhooks) | | `WALLET` | [Wallet events](/api/wallet-api/webhooks) | Each type needs its matching product enabled on your account. Registering returns a signing secret (`whsec_…`) **once**. It is never shown again on any read — store it before closing the dialog. If you lose it, rotate to get a new one; rotation invalidates the old secret immediately, with no overlap window. Your signing secret has no relationship to your API key. Rotating one does not affect the other. ## Envelope Every delivery, every product: ```json { "eventId": "payout.updated:b0e6c2f4-…:SETTLED", "event": "payout.updated", "webhookType": "PAYOUT", "timestamp": "2026-07-16T09:31:05.000Z", "data": {} } ``` | Field | Description | |-------|-------------| | `eventId` | Unique per event. **Key your idempotency on this** | | `event` | Event name, e.g. `payout.updated` | | `webhookType` | `CHECKOUT`, `PAYOUT`, or `WALLET` | | `timestamp` | ISO 8601 | | `data` | Product-specific payload — see the product's events page | ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `X-Blox-Timestamp` | Unix seconds when the payload was signed | | `X-Blox-Signature` | `sha256=` + hex HMAC-SHA256 of `"{timestamp}.{rawBody}"` | ## Verify the signature HMAC-SHA256 over `"{X-Blox-Timestamp}.{rawBody}"`, keyed with your endpoint's `whsec_…` secret. Two things silently break this: * **Use the `X-Blox-Timestamp` header, not the `timestamp` field in the body.** The header is Unix seconds; the body field is ISO 8601. They are not interchangeable, and the body one will never verify. * **Verify over the raw body bytes, before JSON parsing.** Re-serializing changes them and the signature will not match. ```bash printf '%s.%s' "$X_BLOX_TIMESTAMP" "$RAW_BODY" \ | openssl dgst -sha256 -hmac "$BLOX_WEBHOOK_SECRET" -hex ``` ```javascript const payload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]); const expected = `sha256=${createHmac("sha256", process.env.BLOX_WEBHOOK_SECRET) .update(payload) .digest("hex")}`; const valid = signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); ``` ```python payload = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new( os.environ["BLOX_WEBHOOK_SECRET"].encode(), payload, hashlib.sha256, ).hexdigest() valid = hmac.compare_digest(signature, expected) ``` ```go mac := hmac.New(sha256.New, []byte(os.Getenv("BLOX_WEBHOOK_SECRET"))) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) valid := subtle.ConstantTimeCompare( []byte(signature), []byte(expected), ) == 1 ``` ```java var mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec( System.getenv("BLOX_WEBHOOK_SECRET").getBytes(UTF_8), "HmacSHA256" )); mac.update((timestamp + ".").getBytes(UTF_8)); String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody)); boolean valid = MessageDigest.isEqual( signature.getBytes(UTF_8), expected.getBytes(UTF_8) ); ``` ```csharp using var hmac = new HMACSHA256( Encoding.UTF8.GetBytes( Environment.GetEnvironmentVariable("BLOX_WEBHOOK_SECRET")! ) ); var payload = Encoding.UTF8 .GetBytes(timestamp + ".") .Concat(rawBody) .ToArray(); var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(payload)) .ToLowerInvariant(); var valid = CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected) ); ``` Compare in constant time, and reject deliveries whose `X-Blox-Timestamp` is more than **300 seconds** from your server clock. ## Delivery policy | | Value | |--|-------| | Timeout | 10 seconds | | Retries | Up to 5 attempts, exponential backoff (~1m, 2m, 4m, 8m) | | Redirects | Not followed — a `3xx` counts as a failure | | URL | Publicly reachable; must not resolve to private or local addresses | Return **2xx** quickly and do the work asynchronously. After the fifth failed attempt the event is dropped, so treat webhooks as the fast path and a status poll as your backstop. **Make handlers idempotent on `eventId`.** Deliveries are not deduplicated on our side, so a retry of a delivery your server actually received will arrive again. Record the `eventId` and treat a repeat as a no-op. ## URL requirements * Publicly reachable from the internet * HTTPS recommended (plain HTTP currently accepted) * Must not resolve to private or local network addresses * Redirects (3xx) are treated as failures ## Testing Use a tunnel (e.g. [ngrok](https://ngrok.com)) for local endpoints, then trigger sandbox activity — create a checkout, send a payout, or deposit to a beneficiary address. # Rate Limits Every account has an overall ceiling (below). Individual routes carry their own, tighter per-route limits, counted per account over a rolling 60-second window. Whichever you hit first returns `429` — the per-route limits all sit inside the account ceiling, so a single route can never consume your whole budget. ## Per-route write limits Each is counted per account over a rolling 60-second window. | Endpoint | Limit | |----------|-------| | `POST /v1/payouts` | 300/min | | `POST /v1/wallet/token/withdrawals` | 30/min | | `POST /v1/wallet/fiat/withdrawals` | 30/min | | `POST /v1/payouts/beneficiaries` | 20/min | | [`POST /v1/wallet/bank-accounts/{bankAccountId}/address`](/api/wallet-api/endpoints#create-a-deposit-trigger-address) | 20/min | | [`POST /v1/wallet/deposits/check-missed`](/api/wallet-api/endpoints#check-for-a-missed-deposit) | 30/min | | [`POST /v1/wallet/deposits/{id}/redrive`](/api/wallet-api/endpoints#retry-a-deposit) | 60/min | | [`POST /v1/payouts/beneficiaries/{beneficiaryId}/address`](/api/payout-api/endpoints#create-a-deposit-trigger-address) | 20/min | | Wallet whitelist changes — `POST`, `PATCH` and `DELETE` on [`/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist`](/api/wallet-api/endpoints#allow-a-sender) | 60/min, shared between the three | | Payout whitelist changes — `POST`, `PATCH` and `DELETE` on [`/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist`](/api/payout-api/endpoints#allow-a-sender) | 60/min, shared between the three | | [`POST /v1/payouts/deposits/check-missed`](/api/payout-api/endpoints#check-for-a-missed-deposit) | 30/min | | [`POST /v1/payouts/deposits/{id}/redrive`](/api/payout-api/endpoints#retry-a-deposit) | 60/min | | `POST /v1/checkout` | 120/min | ## Per-route read limits | Endpoint | Limit | |----------|-------| | `GET /v1/payouts/{id}` and `GET /v1/payouts/active-banks` | 600/min, shared between the two | | `GET /v1/payout/prefund/balance` | 60/min | ## Account ceiling Applied across every endpoint, per account. | Environment | Rate Limit | |-------------|------------| | Sandbox | 100 requests/minute | | Production | 1000 requests/minute | Sandbox is deliberately tight. A load test that passes in sandbox at 100/min tells you nothing about production headroom — size against the production ceiling, and treat the per-route numbers above as sub-budgets within it. ## Exceeded Limits Exceeding a limit returns `429` in the standard merchant API error shape: ```json { "code": "RATE_LIMITED", "message": "Too many payout requests. Please try again later.", "requestId": "…" } ``` Retry with exponential backoff. Do not assume rate-limit metadata headers are present on responses. A `429` on a write means the request never ran, so retrying with the **same** `Idempotency-Key` is safe and is what you should do. See [Idempotency](/api/idempotency#when-a-request-fails). ## Best Practices 1. **Implement exponential backoff** — When you receive a 429, wait before retrying 2. **Cache responses** — Reduce API calls by caching data where appropriate 3. **Don't poll for payout status** — Register a [`PAYOUT` webhook](/api/payout-api/webhooks) and act on `payout.updated`. Read the payout back only to reconcile or to recover a missed delivery. Prefund top-ups have webhooks too (`payout.prefund_completed`), so the balance endpoint does not need polling either — read it back when an event fires. # Wallet API Read your BLOX wallet and move money out in three ways: * Send **MYRC** to an external EVM or Solana address. * Withdraw **MYR** to a verified bank account linked to your BLOX account. * Have MYR withdrawn to that bank account **automatically**, whenever tokens arrive at an address you set aside for it. This guide follows the integration sequence from access setup to final-status reconciliation. See the [API Reference](/api/wallet-api/endpoints) for the complete request and response contracts. ## Prerequisites Before you integrate, prepare: | Requirement | How to get it | |-------------|---------------| | Wallet API access | Ask BLOX to enable Wallet on your sandbox account | | API key and signer key id | Ask BLOX for API access | | Ed25519 key pair | Generate it yourself; register only the public key with BLOX | | Funded wallet | Deposit MYRC on-chain or buy it through the BLOX app or [Onramp API](/api/onramp-api/overview) | | Linked bank account | Required for fiat withdrawal; the account must be active and verified | | Public HTTPS webhook URL | Required only for automatic withdrawal on deposit; register a `WALLET` webhook under **Devtools → Webhooks** | Use the sandbox base URL while integrating: ```text https://api.sandbox.blox.my ``` Production uses `https://api.blox.my`. Read requests require `blox-api-key`. Write requests also require an [HTTP message signature](/api/authentication/signature), and—where shown—an `Idempotency-Key` UUID v4 persisted before the first request. ## How it works ```mermaid sequenceDiagram participant Backend as Your Backend participant API as BLOX Wallet API participant Destination as Blockchain or Bank Backend->>API: GET balance alt Transfer MYRC on-chain Backend->>API: GET networks Backend->>API: POST token withdrawal else Withdraw MYR to a bank Backend->>API: GET bank accounts Backend->>API: POST fiat withdrawal else Withdraw MYR automatically Backend->>API: POST bank account trigger address Backend->>API: POST allowed sender Backend->>Destination: Send tokens to the trigger address API-->>Backend: wallet.deposit.updated end API-->>Backend: Withdrawal id + initial status API->>Destination: Process transfer Backend->>API: GET withdrawal/{id} API-->>Backend: Current status ``` The two flows share these rules: * Amounts are in **sen**: integers in requests and strings in responses. `10000` means RM100.00. * The create response returns an `id`. Store it and read that withdrawal for status updates. * `GET /v1/wallet/transactions` is account history for reconciliation, not the best way to monitor one withdrawal. * Reuse the same `Idempotency-Key` after a timeout or `5xx`. A new key represents a new transfer and may move money twice. ### Token transfer lifecycle ```text CREATED ──► PENDING ──► PROCESSING ──► COMPLETED │ │ │ └────────────┴────────────┴──────► FAILED ``` EVM transfers start at `CREATED`; Solana transfers start at `PENDING`. A transfer is final at `COMPLETED` or `FAILED`. ### Fiat withdrawal lifecycle ```text PENDING ──► PROCESSING ──► COMPLETED │ │ ├──────────────┴──────► FAILED └─────────────────────► REJECTED ``` A fiat withdrawal is final at `COMPLETED`, `FAILED`, or `REJECTED`. Bank credit is normally T+1 working day; requests after 5 PM MYT or on weekends start processing on the next working day. ## Step-by-step guide ### Step 1: Configure your application Generate an Ed25519 key pair and send only the public key to BLOX: ```bash openssl genpkey -algorithm Ed25519 -out blox_private_key.pem openssl pkey -in blox_private_key.pem -pubout -out blox_public_key.pem ``` Set your sandbox credentials. The examples below assume you copied the Node.js `signBlox` or Python `sign_blox` helper from [Request Signing](/api/authentication/signature). ```javascript const BASE_URL = "https://api.sandbox.blox.my"; const API_KEY = process.env.BLOX_API_KEY; ``` ```python import os import requests BASE_URL = "https://api.sandbox.blox.my" API_KEY = os.environ["BLOX_API_KEY"] ``` Keep the private key on your server. Never send it to BLOX or expose it in frontend code. ### Step 2: Check the wallet balance Read `walletBalance` and verify it covers the amount you intend to send. ```javascript const response = await fetch(`${BASE_URL}/v1/wallet/balance`, { headers: { "blox-api-key": API_KEY }, }); if (!response.ok) throw new Error(await response.text()); const balance = await response.json(); console.log(balance.walletBalance); // string in sen ``` ```python response = requests.get( f"{BASE_URL}/v1/wallet/balance", headers={"blox-api-key": API_KEY}, timeout=10, ) response.raise_for_status() balance = response.json() print(balance["walletBalance"]) # string in sen ``` See [`GET /v1/wallet/balance`](/api/wallet-api/endpoints#get-wallet-balance) for the response fields. ### Step 3: Choose a withdrawal flow Choose one path based on the destination: | Destination | Resolve first | Create request | |-------------|---------------|----------------| | External EVM or Solana address | `GET /v1/wallet/networks` | `POST /v1/wallet/token/withdrawals` | | Linked Malaysian bank account | `GET /v1/wallet/bank-accounts` | `POST /v1/wallet/fiat/withdrawals` | | Linked bank account, on every deposit | `GET /v1/wallet/bank-accounts` | None — see [Step 6](#step-6-optional-withdraw-automatically-on-deposit) | Do not send a bank payout through the token route or use the Payout API for withdrawing your own Wallet balance. The [Payout API](/api/payout-api/overview) is for merchant payouts from a separate prefund balance. ### Step 4A: Transfer MYRC on-chain Resolve `tokenId` at runtime because sandbox and production use different IDs. Choose a network matching the destination address format: EVM addresses start with `0x`; Solana addresses use Base58. ```javascript import { randomUUID } from "node:crypto"; const networksResponse = await fetch(`${BASE_URL}/v1/wallet/networks`, { headers: { "blox-api-key": API_KEY }, }); if (!networksResponse.ok) throw new Error(await networksResponse.text()); const destination = "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21"; const networks = await networksResponse.json(); const network = networks.find( (candidate) => (candidate.type === "EVM") === destination.startsWith("0x"), ); const token = network?.tokens.find((candidate) => candidate.symbol === "MYRC"); if (!token) throw new Error("No active MYRC token matches the address format"); const path = "/v1/wallet/token/withdrawals"; const body = { tokenId: token.id, addressTo: destination, amount: 10000, reference: "ORDER-4471", }; const idempotencyKey = randomUUID(); // persist before sending const response = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const withdrawal = await response.json(); console.log(withdrawal.id, withdrawal.status); ``` ```python import uuid networks_response = requests.get( f"{BASE_URL}/v1/wallet/networks", headers={"blox-api-key": API_KEY}, timeout=10, ) networks_response.raise_for_status() destination = "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21" network = next( candidate for candidate in networks_response.json() if (candidate["type"] == "EVM") == destination.startswith("0x") ) token = next(candidate for candidate in network["tokens"] if candidate["symbol"] == "MYRC") path = "/v1/wallet/token/withdrawals" body = { "tokenId": token["id"], "addressTo": destination, "amount": 10000, "reference": "ORDER-4471", } idempotency_key = str(uuid.uuid4()) # persist before sending headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"{BASE_URL}{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() withdrawal = response.json() print(withdrawal["id"], withdrawal["status"]) ``` The requested `amount` is the total wallet debit. Network fees, when applicable, are deducted before delivery; use the response `amount` and `fee` for reconciliation. See [`POST /v1/wallet/token/withdrawals`](/api/wallet-api/endpoints#create-token-transfer) for limits, fields, statuses, and errors. ### Step 4B: Withdraw MYR to a bank List linked accounts and choose an active, verified account. Then create the withdrawal with its `id`. ```javascript import { randomUUID } from "node:crypto"; const accountsResponse = await fetch(`${BASE_URL}/v1/wallet/bank-accounts`, { headers: { "blox-api-key": API_KEY }, }); if (!accountsResponse.ok) throw new Error(await accountsResponse.text()); const accounts = await accountsResponse.json(); const account = accounts.data.find((candidate) => candidate.verified); if (!account) throw new Error("No verified bank account found"); const path = "/v1/wallet/fiat/withdrawals"; const body = { bankAccountId: account.id, amount: 50000, reference: "WD-4471", }; const idempotencyKey = randomUUID(); // persist before sending const response = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const withdrawal = await response.json(); console.log(withdrawal.id, withdrawal.status); ``` ```python import uuid accounts_response = requests.get( f"{BASE_URL}/v1/wallet/bank-accounts", headers={"blox-api-key": API_KEY}, timeout=10, ) accounts_response.raise_for_status() account = next(item for item in accounts_response.json()["data"] if item["verified"]) path = "/v1/wallet/fiat/withdrawals" body = { "bankAccountId": account["id"], "amount": 50000, "reference": "WD-4471", } idempotency_key = str(uuid.uuid4()) # persist before sending headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"{BASE_URL}{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() withdrawal = response.json() print(withdrawal["id"], withdrawal["status"]) ``` See [Bank Accounts](/api/wallet-api/endpoints#list-bank-accounts) and [`POST /v1/wallet/fiat/withdrawals`](/api/wallet-api/endpoints#create-fiat-withdrawal) for the complete contracts. ### Step 5: Monitor the withdrawal Poll the resource-specific read endpoint with the `id` from the create response: * Token transfer: `GET /v1/wallet/token/withdrawals/{id}` * Fiat withdrawal: `GET /v1/wallet/fiat/withdrawals/{id}` Use `GET /v1/wallet/transactions` later to reconcile all wallet movements. A new EVM transfer is absent from that list while it remains `CREATED`, so the resource-specific endpoint is authoritative from creation. ### Step 6 (optional): Withdraw automatically on deposit Ask BLOX to enable automatic withdrawal on deposit, then give a linked bank account its own on-chain address. Tokens sent to that address are converted and paid into that bank account without a create request — the deposit is the instruction. Set it up once: 1. `POST /v1/wallet/bank-accounts/{bankAccountId}/address` returns the address. A bank account has one address for its lifetime, so calling again returns the same one. 2. `POST /v1/wallet/bank-accounts/{bankAccountId}/address/whitelist` for each address you will send from. The second step is not optional. **A trigger address accepts nothing until you allow a sender.** Deposits from any other address are refused with `sender_not_allowed`, and the tokens simply stay in your wallet. ```javascript const addressPath = `/v1/wallet/bank-accounts/${bankAccountId}/address`; const addressResponse = await fetch(`${BASE_URL}${addressPath}`, { method: "POST", headers: { "blox-api-key": API_KEY, ...signBlox("POST", addressPath) }, }); if (!addressResponse.ok) throw new Error(await addressResponse.text()); const { address } = await addressResponse.json(); const allowPath = `${addressPath}/whitelist`; const allowBody = { address: treasuryAddress, label: "Treasury wallet" }; await fetch(`${BASE_URL}${allowPath}`, { method: "POST", headers: { "blox-api-key": API_KEY, "Content-Type": "application/json", ...signBlox("POST", allowPath, allowBody), }, body: JSON.stringify(allowBody), }); console.log(address); // send tokens here from treasuryAddress ``` ```python address_path = f"/v1/wallet/bank-accounts/{bank_account_id}/address" headers = sign_blox("POST", address_path) headers["blox-api-key"] = API_KEY address_response = requests.post( f"{BASE_URL}{address_path}", headers=headers, timeout=10 ) address_response.raise_for_status() address = address_response.json()["address"] allow_path = f"{address_path}/whitelist" allow_body = {"address": treasury_address, "label": "Treasury wallet"} headers = sign_blox("POST", allow_path, allow_body) headers.update({"blox-api-key": API_KEY, "Content-Type": "application/json"}) requests.post( f"{BASE_URL}{allow_path}", json=allow_body, headers=headers, timeout=10 ).raise_for_status() print(address) # send tokens here from treasury_address ``` From then on, each deposit produces one bank transfer. You learn about it from [`wallet.deposit.updated`](/api/wallet-api/webhooks), which arrives once the deposit is credited and carries the withdrawal it triggered — including a refusal, so a deposit you sent from an address you had not allowed says so in the same event. One deposit produces at most one bank transfer, so sending to the same address twice pays twice, and nothing pays twice for a single deposit. If a deposit is refused for a reason you can fix, fix it and call [`POST /v1/wallet/deposits/{id}/redrive`](/api/wallet-api/endpoints#retry-a-deposit). If you sent tokens and heard nothing at all, [`POST /v1/wallet/deposits/check-missed`](/api/wallet-api/endpoints#check-for-a-missed-deposit) with the transaction hash is the way back. Before switching to production, change the base URL and credentials, resolve the production `tokenId`, verify the production bank account, persist every idempotency key before sending, and respect the 30-per-minute create limit. # Wallet API Reference All Wallet endpoints in integration order. Use `https://api.sandbox.blox.my` while testing and `https://api.blox.my` in production. Read requests require `blox-api-key`. Write requests require the API key, an [HTTP message signature](/api/authentication/signature), and—where shown—an `Idempotency-Key` UUID v4 persisted before the first request. Amounts are integers in **sen** in request bodies and strings in responses. Every error uses `{ "code": "…", "message": "…", "requestId": "…" }`; branch on `code`, never `message`. See [Errors](/api/errors) for shared behavior. | Method | Endpoint | Purpose | |--------|----------|---------| | `GET` | [`/v1/wallet/address`](#get-wallet-address) | Read EVM and Solana deposit addresses | | `GET` | [`/v1/wallet/balance`](#get-wallet-balance) | Read available and pending balances | | `GET` | [`/v1/wallet/networks`](#get-supported-networks) | Resolve active networks and token IDs | | `POST` | [`/v1/wallet/token/withdrawals`](#create-token-transfer) | Send tokens to an external address | | `GET` | [`/v1/wallet/token/withdrawals/{id}`](#get-token-transfer) | Read one token transfer's status | | `GET` | [`/v1/wallet/bank-accounts`](#list-bank-accounts) | List linked bank accounts | | `POST` | [`/v1/wallet/fiat/withdrawals`](#create-fiat-withdrawal) | Withdraw MYR to a linked bank account | | `GET` | [`/v1/wallet/fiat/withdrawals/{id}`](#get-fiat-withdrawal) | Read one fiat withdrawal's status | | `POST` | [`/v1/wallet/bank-accounts/{bankAccountId}/address`](#create-a-deposit-trigger-address) | Give a bank account a deposit trigger address | | `GET` | [`/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist`](#list-allowed-senders) | List the senders allowed to trigger it | | `POST` | [`/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist`](#allow-a-sender) | Allow a sender | | `PATCH` | [`/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist/{senderId}`](#disable-or-re-enable-a-sender) | Pause a sender without losing it | | `DELETE` | [`/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist/{senderId}`](#remove-an-allowed-sender) | Stop allowing a sender | | `GET` | [`/v1/wallet/deposits/{id}`](#get-a-deposit) | Read a deposit and the withdrawal it triggered | | `POST` | [`/v1/wallet/deposits/check-missed`](#check-for-a-missed-deposit) | Have BLOX look at a transaction it never picked up | | `POST` | [`/v1/wallet/deposits/{id}/redrive`](#retry-a-deposit) | Retry a deposit that produced no withdrawal | | `GET` | [`/v1/wallet/transactions`](#list-wallet-transactions) | List wallet movements for reconciliation | ## Get wallet address Returns the wallet's deposit addresses. Missing addresses are created automatically. ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/address" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.blox.my/v1/wallet/address", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/wallet/address", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest("GET", "https://api.blox.my/v1/wallet/address", nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/wallet/address")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync("https://api.blox.my/v1/wallet/address"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "evmAddress": "0x742d35Cc6634C0532925a3b333Bc9e1234f8bD21", "solAddress": "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC99PZ8okm99hy" } ``` | Field | Type | Description | |-------|------|-------------| | `evmAddress` | string | Deposit address shared by supported EVM networks | | `solAddress` | string | Solana deposit address |
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get wallet balance Returns spendable and pending wallet balances. ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/balance" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.blox.my/v1/wallet/balance", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/wallet/balance", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest("GET", "https://api.blox.my/v1/wallet/balance", nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/wallet/balance")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync("https://api.blox.my/v1/wallet/balance"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "walletBalance": "250000", "pendingWithdrawal": "0" } ``` | Field | Type | Description | |-------|------|-------------| | `walletBalance` | string | Spendable wallet balance in sen | | `pendingWithdrawal` | string | Balance currently involved in outbound withdrawals, in sen |
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get supported networks Returns active networks with their active tokens. Resolve `tokenId` from this endpoint at runtime; IDs differ between sandbox and production. | Query | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | No | Return one network by network ID | | `tokenId` | string (UUID) | No | Return the network containing this token | ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/networks" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.blox.my/v1/wallet/networks", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/wallet/networks", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest("GET", "https://api.blox.my/v1/wallet/networks", nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/wallet/networks")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync("https://api.blox.my/v1/wallet/networks"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` Each network includes `id`, `name`, `type`, and `tokens`. Use a token's `id` as `tokenId` in Wallet transfers and Onramp checkouts. Match EVM tokens to `0x` addresses and Solana tokens to Base58 addresses. Contract addresses and chain IDs also appear on [Environments & Chains](/api/environments); prefer this endpoint at runtime.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `id` or `tokenId` is malformed | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For a malformed `tokenId`: ```json { "code": "VALIDATION_FAILED", "message": "tokenId: Invalid UUID", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create token transfer Transfers a token from the BLOX wallet to an external address. The endpoint is limited to **30 requests per minute** per account. **Header** | Header | Required | Description | |--------|----------|-------------| | `Idempotency-Key` | Yes | UUID v4 persisted for this transfer before the first request | **Body** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `tokenId` | string (UUID) | Yes | Active token | ID from `GET /v1/wallet/networks` | | `addressTo` | string | Yes | Must match the token network | Recipient wallet address | | `amount` | integer | Yes | `1000`–`100000000` sen | Total wallet debit (RM10–RM1,000,000) | | `reference` | string | No | Maximum 32 characters | Your reconciliation reference | EVM addresses use `0x` plus 40 hexadecimal characters. Solana addresses use Base58. Transfers to your own deposit address, a contract address, or another restricted destination are rejected. The requested `amount` is the total wallet debit. Network fees, when applicable, are deducted before delivery; the response gives the net `amount` and separate `fee`. ```bash curl --fail-with-body -X POST "https://api.sandbox.blox.my/v1/wallet/token/withdrawals" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"tokenId":"550e8400-e29b-41d4-a716-446655440000","addressTo":"0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21","amount":10000,"reference":"ORDER-4471"}' ``` ```javascript const path = "/v1/wallet/token/withdrawals"; const body = { tokenId: "550e8400-e29b-41d4-a716-446655440000", addressTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21", amount: 10000, reference: "ORDER-4471", }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": process.env.IDEMPOTENCY_KEY, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python path = "/v1/wallet/token/withdrawals" body = { "tokenId": "550e8400-e29b-41d4-a716-446655440000", "addressTo": "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21", "amount": 10000, "reference": "ORDER-4471", } headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"], }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"tokenId":"550e8400-e29b-41d4-a716-446655440000","addressTo":"0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21","amount":10000,"reference":"ORDER-4471"}`) var body any json.Unmarshal(raw, &body) path := "/v1/wallet/token/withdrawals" headers, _ := signBlox("POST", path, body) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(raw)) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", os.Getenv("IDEMPOTENCY_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java String path = "/v1/wallet/token/withdrawals"; String body = """ {"tokenId":"550e8400-e29b-41d4-a716-446655440000","addressTo":"0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21","amount":10000,"reference":"ORDER-4471"} """; var headers = signBlox("POST", path, body); var builder = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", System.getenv("IDEMPOTENCY_KEY")) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp const string path = "/v1/wallet/token/withdrawals"; var body = """ {"tokenId":"550e8400-e29b-41d4-a716-446655440000","addressTo":"0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21","amount":10000,"reference":"ORDER-4471"} """; var headers = SignBlox("POST", path, body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my" + path ); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("IDEMPOTENCY_KEY")); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "id": "9b3bd9db-75d8-44dc-a74b-16fe968a01c7", "status": "CREATED", "toAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21", "amount": "10000", "fee": "0", "reference": "ORDER-4471", "source": "API", "tokenId": "550e8400-e29b-41d4-a716-446655440000", "token": { "symbol": "MYRC", "name": "MYR Coin", "network": { "id": "ethereum", "name": "Ethereum", "type": "EVM" } }, "txHash": null, "confirmations": null, "confirmedAt": null, "createdAt": "2026-01-16T10:30:00.000Z", "updatedAt": "2026-01-16T10:30:00.000Z" } ``` The initial status is `CREATED` for EVM transfers and `PENDING` for Solana transfers. See the [Token transfer object](#token-transfer-object) for every field and status. Persist one UUID v4 before sending. Reuse that key after no response, a timeout, `202`, or `5xx`. Reusing it with the same body returns the original transfer for 24 hours; a different body returns `422 VALIDATION_FAILED`. After a `4xx`, fix the request and use a new key.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | The idempotency key, token, or destination is not accepted | | `400` | `VALIDATION_FAILED` | The body, address, amount, or reference is invalid | | `400` | `INSUFFICIENT_BALANCE` | The wallet cannot cover the requested debit | | `400` | `LIMIT_EXCEEDED` | A configured daily or monthly limit is reached | | `401` | `UNAUTHORIZED` | API key or request signature is missing or invalid | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_FROZEN` | The account is frozen | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `422` | `VALIDATION_FAILED` | The idempotency key was used with another body | | `429` | `RATE_LIMITED` | The 30-per-minute create limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Missing idempotency key", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 VALIDATION\_FAILED For a request with `amount: 999`: ```json { "code": "VALIDATION_FAILED", "message": "amount: Amount can't be less than 10 MYR", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 INSUFFICIENT\_BALANCE ```json { "code": "INSUFFICIENT_BALANCE", "message": "Insufficient MYRC balance in wallet.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 LIMIT\_EXCEEDED ```json { "code": "LIMIT_EXCEEDED", "message": "Daily transaction limit exceeded.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 FEATURE\_DISABLED ```json { "code": "FEATURE_DISABLED", "message": "Feature wallet_api is not enabled for this account.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_FROZEN ```json { "code": "ACCOUNT_FROZEN", "message": "Your account has been frozen. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_TERMINATED ```json { "code": "ACCOUNT_TERMINATED", "message": "Your account has been terminated. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_PENDING\_VERIFICATION ```json { "code": "ACCOUNT_PENDING_VERIFICATION", "message": "Your account is pending verification. This action is not allowed for this account at this time.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
422 VALIDATION\_FAILED ```json { "code": "VALIDATION_FAILED", "message": "Idempotency key used with different request payload", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
429 RATE\_LIMITED ```json { "code": "RATE_LIMITED", "message": "Too many token withdrawals. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
500 INTERNAL\_ERROR ```json { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get token transfer Returns one token transfer from the moment it is created. Use this endpoint to monitor status. | Path | Type | Description | |------|------|-------------| | `id` | string (UUID) | `id` returned by the create request | ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/token/withdrawals/$WITHDRAWAL_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/wallet/token/withdrawals/${process.env.WITHDRAWAL_ID}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( f"https://api.blox.my/v1/wallet/token/withdrawals/{os.environ['WITHDRAWAL_ID']}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go url := "https://api.blox.my/v1/wallet/token/withdrawals/" + os.Getenv("WITHDRAWAL_ID") req, _ := http.NewRequest("GET", url, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var url = "https://api.blox.my/v1/wallet/token/withdrawals/" + System.getenv("WITHDRAWAL_ID"); var request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var id = Environment.GetEnvironmentVariable("WITHDRAWAL_ID"); var response = await client.GetAsync( $"https://api.blox.my/v1/wallet/token/withdrawals/{id}" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`:** [Token transfer object](#token-transfer-object). Unknown and cross-account IDs return `404 NOT_FOUND`. An EVM transfer is absent from `GET /v1/wallet/transactions` while it remains `CREATED`; this resource endpoint is therefore the authoritative status from creation.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | The transfer is unknown or belongs to another account | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

404 NOT\_FOUND ```json { "code": "NOT_FOUND", "message": "Token withdrawal not found", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## List bank accounts Returns active bank accounts linked to the BLOX account. Use a verified account's `id` as `bankAccountId` when creating a fiat withdrawal. | Query | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `search` | string | No | — | Filter by account name | | `limit` | integer | No | 1–100, default `25` | Records per page | | `cursor` | string | No | Previous page's last `id` | Omit for the first page | ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/bank-accounts?limit=25" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( "https://api.blox.my/v1/wallet/bank-accounts?limit=25", { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/wallet/bank-accounts", params={"limit": 25}, headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/wallet/bank-accounts?limit=25", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/wallet/bank-accounts?limit=25")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/wallet/bank-accounts?limit=25" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "data": [ { "id": "6639e2e8-9d71-4a77-9fd7-34fcd0ce5355", "name": "Business account", "accountNumber": "123456789012", "verified": true, "bank": { "code": "MBBM", "name": "Maybank" }, "createdAt": "2026-01-10T04:00:00.000Z", "updatedAt": "2026-01-10T04:00:00.000Z" } ], "hasMore": false, "nextCursor": null } ``` | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Send as `bankAccountId` when withdrawing | | `name` | string | Account label | | `accountNumber` | string | Unmasked bank account number | | `verified` | boolean | Whether BLOX has verified the account | | `bank` | object | null | `{ code, name }` | | `createdAt` / `updatedAt` | string | ISO 8601 timestamps | Iterate until `hasMore` is false. There is no `total`.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | A query parameter is malformed or outside its constraint | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For `?limit=101`: ```json { "code": "VALIDATION_FAILED", "message": "limit: Number must be less than or equal to 100", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create fiat withdrawal Withdraws MYR from the BLOX wallet to a linked bank account. The endpoint is limited to **30 requests per minute** per account. **Header** | Header | Required | Description | |--------|----------|-------------| | `Idempotency-Key` | Yes | UUID v4 persisted for this withdrawal before the first request | **Body** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `bankAccountId` | string (UUID) | Yes | Active linked account | ID from `GET /v1/wallet/bank-accounts` | | `amount` | integer | Yes | `1000`–`100000000` sen | Withdrawal amount (RM10–RM1,000,000) | | `reference` | string | No | Maximum 20 characters | Your reconciliation reference | Individual accounts have an additional RM300,000 maximum; business accounts can use the full per-request range. Other account-level limits may apply. ```bash curl --fail-with-body -X POST "https://api.sandbox.blox.my/v1/wallet/fiat/withdrawals" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"bankAccountId":"6639e2e8-9d71-4a77-9fd7-34fcd0ce5355","amount":50000,"reference":"WD-4471"}' ``` ```javascript const path = "/v1/wallet/fiat/withdrawals"; const body = { bankAccountId: "6639e2e8-9d71-4a77-9fd7-34fcd0ce5355", amount: 50000, reference: "WD-4471", }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": process.env.IDEMPOTENCY_KEY, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python path = "/v1/wallet/fiat/withdrawals" body = { "bankAccountId": "6639e2e8-9d71-4a77-9fd7-34fcd0ce5355", "amount": 50000, "reference": "WD-4471", } headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"], }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"bankAccountId":"6639e2e8-9d71-4a77-9fd7-34fcd0ce5355","amount":50000,"reference":"WD-4471"}`) var body any json.Unmarshal(raw, &body) path := "/v1/wallet/fiat/withdrawals" headers, _ := signBlox("POST", path, body) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(raw)) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", os.Getenv("IDEMPOTENCY_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java String path = "/v1/wallet/fiat/withdrawals"; String body = """ {"bankAccountId":"6639e2e8-9d71-4a77-9fd7-34fcd0ce5355","amount":50000,"reference":"WD-4471"} """; var headers = signBlox("POST", path, body); var builder = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", System.getenv("IDEMPOTENCY_KEY")) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp const string path = "/v1/wallet/fiat/withdrawals"; var body = """ {"bankAccountId":"6639e2e8-9d71-4a77-9fd7-34fcd0ce5355","amount":50000,"reference":"WD-4471"} """; var headers = SignBlox("POST", path, body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my" + path ); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("IDEMPOTENCY_KEY")); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "id": "1de31ea3-c2b5-454b-a1f0-547c21738aef", "status": "PENDING", "amount": "50000", "fee": "0", "reference": "WD-4471", "refId": "FW-20260116-A1B2C3", "source": "API", "destination": { "name": "ADA LOVELACE", "accountNumber": "1234567890", "bank": { "code": "MBBM", "name": "Maybank" } }, "confirmedAt": null, "createdAt": "2026-01-16T10:30:00.000Z", "updatedAt": "2026-01-16T10:30:00.000Z" } ``` See the [Fiat withdrawal object](#fiat-withdrawal-object) for every field and status. Idempotency and retry behavior match token transfers: persist the key before sending and reuse it for the same logical withdrawal.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | The idempotency key or account-specific amount is not accepted | | `400` | `VALIDATION_FAILED` | The body, amount, or reference is invalid | | `400` | `INSUFFICIENT_BALANCE` | The wallet cannot cover the withdrawal | | `400` | `LIMIT_EXCEEDED` | A configured daily or monthly limit is reached | | `401` | `UNAUTHORIZED` | API key or request signature is missing or invalid | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_FROZEN` | The account is frozen | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `404` | `NOT_FOUND` | The bank account is unknown, inactive, or belongs to another account | | `422` | `VALIDATION_FAILED` | The idempotency key was used with another body | | `429` | `RATE_LIMITED` | The 30-per-minute create limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Missing idempotency key", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 VALIDATION\_FAILED For a request with `amount: 999`: ```json { "code": "VALIDATION_FAILED", "message": "amount: Amount can't be less than 10 MYR", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 INSUFFICIENT\_BALANCE ```json { "code": "INSUFFICIENT_BALANCE", "message": "Insufficient MYRC balance in wallet.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 LIMIT\_EXCEEDED ```json { "code": "LIMIT_EXCEEDED", "message": "Daily transaction limit exceeded.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 FEATURE\_DISABLED ```json { "code": "FEATURE_DISABLED", "message": "Feature wallet_api is not enabled for this account.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_FROZEN ```json { "code": "ACCOUNT_FROZEN", "message": "Your account has been frozen. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_TERMINATED ```json { "code": "ACCOUNT_TERMINATED", "message": "Your account has been terminated. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_PENDING\_VERIFICATION ```json { "code": "ACCOUNT_PENDING_VERIFICATION", "message": "Your account is pending verification. This action is not allowed for this account at this time.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
404 NOT\_FOUND ```json { "code": "NOT_FOUND", "message": "Bank account not found", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
422 VALIDATION\_FAILED ```json { "code": "VALIDATION_FAILED", "message": "Idempotency key used with different request payload", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
429 RATE\_LIMITED ```json { "code": "RATE_LIMITED", "message": "Too many fiat withdrawals. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
500 INTERNAL\_ERROR ```json { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get fiat withdrawal Returns one fiat withdrawal. Poll until `COMPLETED`, `FAILED`, or `REJECTED`. | Path | Type | Description | |------|------|-------------| | `id` | string (UUID) | `id` returned by the create request | ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/fiat/withdrawals/$WITHDRAWAL_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/wallet/fiat/withdrawals/${process.env.WITHDRAWAL_ID}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( f"https://api.blox.my/v1/wallet/fiat/withdrawals/{os.environ['WITHDRAWAL_ID']}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go url := "https://api.blox.my/v1/wallet/fiat/withdrawals/" + os.Getenv("WITHDRAWAL_ID") req, _ := http.NewRequest("GET", url, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var url = "https://api.blox.my/v1/wallet/fiat/withdrawals/" + System.getenv("WITHDRAWAL_ID"); var request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var id = Environment.GetEnvironmentVariable("WITHDRAWAL_ID"); var response = await client.GetAsync( $"https://api.blox.my/v1/wallet/fiat/withdrawals/{id}" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`:** [Fiat withdrawal object](#fiat-withdrawal-object). Unknown and cross-account IDs return `404 NOT_FOUND`.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | The withdrawal is unknown or belongs to another account | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

404 NOT\_FOUND ```json { "code": "NOT_FOUND", "message": "Fiat withdrawal not found", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create a deposit trigger address Gives a linked bank account an on-chain address. Tokens sent to it are withdrawn to that bank account automatically, with no create request from you. See [Withdraw automatically on deposit](/api/wallet-api/overview#step-6-optional-withdraw-automatically-on-deposit). The bank account must be active. Calling this again returns the same address — a bank account has one, for its lifetime — so no `Idempotency-Key` is needed. Limited to 20 calls per minute. ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/bank-accounts/$BANK_ACCOUNT_ID/address" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/wallet/bank-accounts/${bankAccountId}/address`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, ...signBlox("POST", path), }, }); if (!response.ok) throw new Error(await response.text()); const { address } = await response.json(); ``` ```python path = f"/v1/wallet/bank-accounts/{bank_account_id}/address" headers = sign_blox("POST", path) headers["blox-api-key"] = os.environ["BLOX_API_KEY"] response = requests.post( f"https://api.sandbox.blox.my{path}", headers=headers, timeout=10 ) response.raise_for_status() address = response.json()["address"] ``` ```go path := "/v1/wallet/bank-accounts/" + bankAccountID + "/address" req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/wallet/bank-accounts/" + bankAccountId + "/address"; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.noBody()); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/wallet/bank-accounts/{bankAccountId}/address"; var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) ```json { "address": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "network": "EVM", "destination": { "type": "BANK_ACCOUNT", "id": "b21f4c77-3e5a-4d90-9c18-6a2f7e0b4d31" } } ``` | Field | Type | Description | |-------|------|-------------| | `address` | string | Send supported tokens here | | `network` | string | `EVM` | | `destination.type` | string | `BANK_ACCOUNT` | | `destination.id` | string (UUID) | The bank account that gets paid | The address ignores every deposit until you allow at least one sender.
Expected errors | Status | `code` | When | |--------|--------|------| | `403` | `FEATURE_DISABLED` | Automatic withdrawal on deposit is not enabled on your account | | `404` | `NOT_FOUND` | Unknown bank account, one belonging to another account, or one that is not active | | `429` | `RATE_LIMITED` | Over 20 calls in the last minute |
## List allowed senders Returns the senders allowed to trigger this bank account's address, oldest first. ```bash curl --fail-with-body \ "https://api.blox.my/v1/wallet/bank-accounts/$BANK_ACCOUNT_ID/address/whitelist" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/wallet/bank-accounts/${bankAccountId}/address/whitelist`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( f"https://api.blox.my/v1/wallet/bank-accounts/{bank_account_id}/address/whitelist", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go url := "https://api.blox.my/v1/wallet/bank-accounts/" + bankAccountID + "/address/whitelist" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) ``` ```java var request = HttpRequest.newBuilder(URI.create( "https://api.blox.my/v1/wallet/bank-accounts/" + bankAccountId + "/address/whitelist")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.blox.my/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json [ { "id": "3f8c1d02-5b47-4a6e-91cd-77e2b0a4f915", "address": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "label": "Treasury wallet", "active": true, "createdAt": "2026-08-07T09:31:05.000Z" } ] ``` | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Pass this to [disable](#disable-or-re-enable-a-sender) or [remove](#remove-an-allowed-sender) the sender | | `address` | string | Stored lowercase | | `label` | string | null | Your own note | | `active` | boolean | Only active senders are accepted; see [disable](#disable-or-re-enable-a-sender) | | `createdAt` | string | ISO 8601 | Disabled senders are listed too. They count toward the 50-sender limit until you remove them. `404` `NOT_FOUND` if the bank account has no trigger address yet. ## Allow a sender Allows one address to trigger withdrawals to this bank account. Up to 50 per address. | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `address` | string | Yes | `0x` and 40 hex characters | The address you send from. Casing is ignored | | `label` | string | No | 1–120 characters | Your own note, returned on reads | ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/bank-accounts/$BANK_ACCOUNT_ID/address/whitelist" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"address":"0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02","label":"Treasury wallet"}' ``` ```javascript const path = `/v1/wallet/bank-accounts/${bankAccountId}/address/whitelist`; const body = { address: senderAddress, label: "Treasury wallet" }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/wallet/bank-accounts/{bank_account_id}/address/whitelist" body = {"address": sender_address, "label": "Treasury wallet"} headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() ``` ```go path := "/v1/wallet/bank-accounts/" + bankAccountID + "/address/whitelist" payload, _ := json.Marshal(map[string]string{ "address": senderAddress, "label": "Treasury wallet", }) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(payload)) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/wallet/bank-accounts/" + bankAccountId + "/address/whitelist"; String payload = """ {"address":"%s","label":"Treasury wallet"}""".formatted(senderAddress); var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.ofString(payload)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist"; var payload = JsonSerializer.Serialize(new { address = senderAddress, label = "Treasury wallet" }); var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) Returns the created sender in the same shape as [List allowed senders](#list-allowed-senders).
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `address` is not a valid address, or `label` is longer than 120 characters | | `400` | `CONFLICT` | That address is already allowed here | | `403` | `LIMIT_EXCEEDED` | The address already has 50 allowed senders | | `404` | `NOT_FOUND` | The bank account has no trigger address, or is not yours |
## Disable or re-enable a sender Turns a sender off without losing it. A disabled sender stops triggering withdrawals immediately, keeps its label, and stays in the list ready to be turned back on. Use this to pause a wallet you expect to send from again; use [remove](#remove-an-allowed-sender) when you are done with it, which is also what frees its place against the 50-sender limit. ```bash curl -X PATCH --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/bank-accounts/$BANK_ACCOUNT_ID/address/whitelist/$SENDER_ID" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"active":false}' ``` ```javascript const path = `/v1/wallet/bank-accounts/${bankAccountId}/address/whitelist/${senderId}`; const body = JSON.stringify({ active: false }); const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "PATCH", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Content-Digest": contentDigest, "Signature-Input": signatureInput, Signature: signature, }, body, }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/wallet/bank-accounts/{bank_account_id}/address/whitelist/{sender_id}" response = requests.patch( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Content-Digest": content_digest, "Signature-Input": signature_input, "Signature": signature, }, data=body, timeout=10, ) response.raise_for_status() ``` ```go path := "/v1/wallet/bank-accounts/" + bankAccountID + "/address/whitelist/" + senderID req, _ := http.NewRequest("PATCH", "https://api.sandbox.blox.my"+path, bytes.NewReader(body)) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Digest", contentDigest) req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/wallet/bank-accounts/" + bankAccountId + "/address/whitelist/" + senderId; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Content-Digest", contentDigest) .header("Signature-Input", signatureInput) .header("Signature", signature) .method("PATCH", HttpRequest.BodyPublishers.ofString(body)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist/{senderId}"; var request = new HttpRequestMessage(HttpMethod.Patch, $"https://api.sandbox.blox.my{path}") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Content-Digest", contentDigest); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Request body | Field | Type | Description | |-------|------|-------------| | `active` | boolean | `false` to disable, `true` to re-enable | ### Success response (`200`) Returns the sender in the same shape as [List allowed senders](#list-allowed-senders).
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `active` is missing or is not a boolean | | `403` | `ACCOUNT_FROZEN` | The account is frozen. [Removing](#remove-an-allowed-sender) a sender still works | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `404` | `NOT_FOUND` | The sender id is unknown or belongs to a different address |
## Remove an allowed sender Stops accepting deposits from that sender and frees its place against the 50-sender limit. Deposits already on their way are unaffected. Sign `@method` and `@path` only; no `Content-Digest` is required. ```bash curl -X DELETE --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/bank-accounts/$BANK_ACCOUNT_ID/address/whitelist/$SENDER_ID" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/wallet/bank-accounts/${bankAccountId}/address/whitelist/${senderId}`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "DELETE", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Signature-Input": signatureInput, // (@method @path) only Signature: signature, }, }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/wallet/bank-accounts/{bank_account_id}/address/whitelist/{sender_id}" response = requests.delete( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Signature-Input": signature_input, # (@method @path) only "Signature": signature, }, timeout=10, ) response.raise_for_status() ``` ```go path := "/v1/wallet/bank-accounts/" + bankAccountID + "/address/whitelist/" + senderID req, _ := http.NewRequest("DELETE", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) // (@method @path) only req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/wallet/bank-accounts/" + bankAccountId + "/address/whitelist/" + senderId; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) // (@method @path) only .header("Signature", signature) .DELETE(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/wallet/bank-accounts/{bankAccountId}/address/whitelist/{senderId}"; var request = new HttpRequestMessage(HttpMethod.Delete, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json { "success": true } ``` `404` `NOT_FOUND` if the sender id is unknown or belongs to a different address. ## Get a deposit Returns a deposit and, when it triggered one, the withdrawal it produced. This is the same payload the [deposit webhook](/api/wallet-api/webhooks) delivers. ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/deposits/$DEPOSIT_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/wallet/deposits/${depositId}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); const deposit = await response.json(); ``` ```python response = requests.get( f"https://api.blox.my/v1/wallet/deposits/{deposit_id}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() deposit = response.json() ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/wallet/deposits/"+depositID, nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) ``` ```java var request = HttpRequest.newBuilder(URI.create( "https://api.blox.my/v1/wallet/deposits/" + depositId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.blox.my/v1/wallet/deposits/{depositId}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json { "id": "5e9d0273-8a41-4c62-b0f7-1d3e8c95a460", "txHash": "0x5d53558791c9346d644d077354420f9a93600acf54eb806ecb9aad077c103ee3", "logIndex": 12, "chainId": 1, "tokenId": "a71c4e08-2f96-4b3d-85ae-6c0f7d21b943", "amount": "100000", "from": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "status": "COMPLETED", "confirmations": 24, "triggerAddress": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "destination": { "type": "BANK_ACCOUNT", "id": "b21f4c77-3e5a-4d90-9c18-6a2f7e0b4d31", "name": "ACME SDN BHD" }, "result": { "status": "COMPLETED", "statusReason": null, "withdrawal": { "id": "c4a80f13-6d29-4e75-83b1-9f0c2a7e5d68", "amount": "100000", "status": "COMPLETED", "refId": "FW-20260807-0001", "reference": "ACME payout", "createdAt": "2026-08-07T09:31:05.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } }, "createdAt": "2026-08-07T09:30:44.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | The deposit id | | `txHash` | string | Transaction that carried the deposit | | `logIndex` | integer | Position of this transfer within the transaction | | `chainId` | integer | Chain the deposit arrived on | | `tokenId` | string (UUID) | Token deposited; matches `GET /v1/wallet/networks` | | `amount` | string | Deposit amount in the token's smallest unit | | `from` | string | The address the tokens were sent from | | `status` | string | `PENDING` while confirming, `COMPLETED` once credited, `FAILED` if it did not credit | | `confirmations` | integer | Confirmations seen so far | | `triggerAddress` | string | null | The address that received it, or `null` for an ordinary deposit | | `destination` | object | null | The bank account being paid, or `null` | | `result` | object | null | The withdrawal outcome, or `null` when nothing was triggered | | `result.status` | string | `PENDING`, `PROCESSING`, `COMPLETED`, or `FAILED` | | `result.statusReason` | string | null | Why it failed. See [Status reason values](#deposit-status-reason-values) | | `result.withdrawal` | object | null | The [fiat withdrawal object](#fiat-withdrawal-object), once one exists | `amount` and `result.withdrawal.amount` use different units — the token's smallest unit and sen. Read each from its own field. `404` `NOT_FOUND` for an unknown deposit id, or one belonging to another account. ### Deposit status reason values `result.statusReason` is `null` unless `result.status` is `FAILED`. | Value | Meaning | |-------|---------| | `sender_not_allowed` | The deposit came from an address you have not allowed | | `inactive_destination` | The bank account was removed or deactivated | | `missing_destination_bank_details` | The bank account has no bank on file | | `feature_disabled` | Automatic withdrawal on deposit is not enabled on your account | | `account_frozen`, `account_suspended`, `account_terminated` | Your account status blocked it | | `withdrawal_amount_out_of_range` | Below the minimum or above the maximum for your account | | `daily_fiat_withdrawal_limit_exceeded` | Daily limit reached | | `monthly_fiat_withdrawal_limit_exceeded` | Monthly limit reached | | `fiat_withdrawal_rejected`, `fiat_withdrawal_failed`, `fiat_withdrawal_cancelled` | The bank transfer did not complete | | `provider_error` | Anything else the rail reported. Contact BLOX with the deposit `id` | The tokens stay credited to your wallet in every case. Where the cause is yours to fix — an address you had not allowed, a bank account you reactivated, a limit that has since rolled over — fix it and [retry the deposit](#retry-a-deposit). ## Check for a missed deposit Asks BLOX to look at a transaction it never picked up. Use it when you sent tokens and nothing arrived. This is the only endpoint that takes a transaction hash. When the transaction turns out to be known already, the response carries the deposit ids — use those with [Get a deposit](#get-a-deposit) and [Retry a deposit](#retry-a-deposit). Limited to 30 calls per minute. | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `txHash` | string | Yes | `0x` and 64 hex characters | Transaction that carried the deposit | | `chainId` | integer | Yes | An active BLOX network | Chain the transaction is on | ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/deposits/check-missed" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"txHash":"0x5d5355...103ee3","chainId":1}' ``` ```javascript const path = "/v1/wallet/deposits/check-missed"; const body = { txHash, chainId: 1 }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const { status, depositIds } = await response.json(); ``` ```python path = "/v1/wallet/deposits/check-missed" body = {"txHash": tx_hash, "chainId": 1} headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() result = response.json() ``` ```go payload, _ := json.Marshal(map[string]any{"txHash": txHash, "chainId": 1}) req, _ := http.NewRequest( "POST", "https://api.sandbox.blox.my/v1/wallet/deposits/check-missed", bytes.NewReader(payload), ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String payload = """ {"txHash":"%s","chainId":1}""".formatted(txHash); var request = HttpRequest.newBuilder(URI.create( "https://api.sandbox.blox.my/v1/wallet/deposits/check-missed")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.ofString(payload)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var payload = JsonSerializer.Serialize(new { txHash, chainId = 1 }); var request = new HttpRequestMessage(HttpMethod.Post, "https://api.sandbox.blox.my/v1/wallet/deposits/check-missed") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) ```json { "status": "ALREADY_DETECTED", "depositIds": ["5e9d0273-8a41-4c62-b0f7-1d3e8c95a460"] } ``` | Field | Type | Description | |-------|------|-------------| | `status` | string | `QUEUED` if the transaction is now being picked up, `ALREADY_DETECTED` if BLOX had it | | `depositIds` | string\[] | The deposits in that transaction. Populated on `ALREADY_DETECTED`, empty on `QUEUED` | Check `status` before reading `depositIds`: an empty array on `QUEUED` means the deposits do not exist yet, not that none were found. One transaction can carry several deposits. On `QUEUED`, wait for the webhook rather than calling again.
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `txHash` is not a valid hash, or `chainId` is not a positive integer | | `403` | `FEATURE_DISABLED` | Automatic withdrawal on deposit is not enabled on your account | | `404` | `NOT_FOUND` | No active BLOX network matches `chainId` | | `429` | `RATE_LIMITED` | Over 30 calls in the last minute |
## Retry a deposit Re-evaluates a deposit that was credited but produced no bank transfer. Use it after fixing the cause — allowing the sender, reactivating the bank account, or waiting for a limit to roll over. Safe to call more than once. A deposit that already paid out returns its existing withdrawal, and no second transfer is created. Limited to 60 calls per minute. No request body. Sign `@method` and `@path` only. ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/wallet/deposits/$DEPOSIT_ID/redrive" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/wallet/deposits/${depositId}/redrive`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Signature-Input": signatureInput, // (@method @path) only Signature: signature, }, }); if (!response.ok) throw new Error(await response.text()); const deposit = await response.json(); ``` ```python path = f"/v1/wallet/deposits/{deposit_id}/redrive" response = requests.post( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Signature-Input": signature_input, # (@method @path) only "Signature": signature, }, timeout=10, ) response.raise_for_status() deposit = response.json() ``` ```go path := "/v1/wallet/deposits/" + depositID + "/redrive" req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) // (@method @path) only req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/wallet/deposits/" + depositId + "/redrive"; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) // (@method @path) only .header("Signature", signature) .POST(HttpRequest.BodyPublishers.noBody()); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/wallet/deposits/{depositId}/redrive"; var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) The deposit, in the same shape as [Get a deposit](#get-a-deposit), read back after the retry.
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | The deposit has not finished confirming, so there is nothing to retry | | `403` | `FEATURE_DISABLED` | Automatic withdrawal on deposit is not enabled on your account | | `404` | `NOT_FOUND` | Unknown deposit, one belonging to another account, or one that did not arrive on a trigger address | | `429` | `RATE_LIMITED` | Over 60 calls in the last minute |
## List wallet transactions Returns wallet movements newest first. Use it for history and reconciliation; use the resource-specific read endpoints to monitor one withdrawal. | Query | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `startDt` | string | No | ISO 8601 datetime | Created on or after this time | | `endDt` | string | No | ISO 8601 datetime | Created on or before this time | | `type` | string | No | `IN` or `OUT` | Direction | | `mode` | string | No | `FIAT` or `TOKEN` | Transaction mode | | `limit` | integer | No | 1–100, default `25` | Records per page | | `cursor` | string | No | Previous response's `nextCursor` | Omit for the first page | ```bash curl --fail-with-body "https://api.blox.my/v1/wallet/transactions?type=OUT&limit=20" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( "https://api.blox.my/v1/wallet/transactions?type=OUT&limit=20", { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/wallet/transactions", params={"type": "OUT", "limit": 20}, headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/wallet/transactions?type=OUT&limit=20", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/wallet/transactions?type=OUT&limit=20")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/wallet/transactions?type=OUT&limit=20" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "data": [ { "transactionId": "9b3bd9db-75d8-44dc-a74b-16fe968a01c7", "type": "OUT", "mode": "TOKEN", "status": "PENDING", "amount": "-10000", "fee": "0", "effectiveAmount": "-10000", "beneficiary": "0x742d35Cc6634C0532925a3b844Bc9e7595f8bD21", "reference": "ORDER-4471", "refId": "", "source": "API", "networkId": "ethereum", "createdAt": "2026-01-16T10:30:00.000Z", "confirmedAt": null } ], "hasMore": true, "nextCursor": "MjAyNi0wMS0xNlQxMDozMDowMC4wMDBafDliM2Jk…" } ``` Pass `nextCursor` back as `cursor` until `hasMore` is false. There is no `total`; cursors are opaque. Rows in `CREATED` status are omitted until the status advances. | Field | Type | Description | |-------|------|-------------| | `transactionId` | string (UUID) | Wallet transaction identifier | | `type` | string | `IN` or `OUT` | | `mode` | string | `FIAT` or `TOKEN` | | `status` | string | Current transaction status | | `amount` | string | Signed amount in sen | | `fee` | string | Fee in sen | | `effectiveAmount` | string | Net signed wallet movement in sen | | `beneficiary` | string | null | Destination address, bank account, or other recipient label | | `reference` | string | null | Your reconciliation reference | | `refId` | string | BLOX reference when available | | `source` | string | Origin of the transaction, such as `API` | | `networkId` | string | null | Blockchain network for token movements | | `createdAt` | string | ISO 8601 creation timestamp | | `confirmedAt` | string | null | ISO 8601 completion timestamp |
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | A query parameter is malformed or outside its constraint | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Wallet API is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For `?limit=101`: ```json { "code": "VALIDATION_FAILED", "message": "limit: Number must be less than or equal to 100", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Shared response schemas ### Token transfer object | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Unique transfer identifier | | `status` | string | `CREATED`, `PENDING`, `PROCESSING`, `COMPLETED`, or `FAILED` | | `toAddress` | string | Recipient wallet address | | `amount` | string | Net amount delivered, in sen | | `fee` | string | Fee deducted from the requested amount, in sen | | `reference` | string | null | Your reconciliation reference | | `source` | string | `API` for API-created transfers | | `tokenId` | string (UUID) | Selected token identifier | | `token` | object | `{ symbol, name, network: { id, name, type } }` | | `txHash` | string | null | On-chain transaction hash after broadcast | | `confirmations` | number | null | Confirmations observed so far | | `confirmedAt` | string | null | ISO 8601 completion time | | `createdAt` / `updatedAt` | string | ISO 8601 timestamps | ### Fiat withdrawal object | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Unique withdrawal identifier | | `status` | string | `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`, or `REJECTED` | | `amount` | string | Withdrawal amount in sen | | `fee` | string | Withdrawal fee in sen | | `reference` | string | null | Your reconciliation reference | | `refId` | string | BLOX withdrawal reference to quote to support | | `source` | string | `API` for API-created withdrawals | | `destination` | object | null | `{ name, accountNumber, bank: { code, name } }` | | `confirmedAt` | string | null | ISO 8601 completion time | | `createdAt` / `updatedAt` | string | ISO 8601 timestamps | # Wallet Webhook Events Account-level webhook events for token deposits and the bank transfers they produce. Register a `WALLET`-type endpoint from the dashboard (**Devtools → Webhooks**). These are the only notification for [automatic withdrawal on deposit](/api/wallet-api/overview#step-6-optional-withdraw-automatically-on-deposit) — you never make a request to start one, so there is no response to read a status from. Registration, signature verification, retries, and URL rules: [Webhooks](/api/webhooks). ## Events | Event | Fires when | |-------|------------| | `wallet.deposit.updated` | A token deposit is credited, or is retried | | `wallet.withdrawal.updated` | A bank transfer to your own account changes status | `wallet.deposit.updated` arrives once the deposit is credited and the withdrawal it triggered has been decided, so a single event tells you the money arrived and what happened next. A deposit that triggered nothing — an ordinary deposit address, or one blocked by your sender list — still produces the event. Deposits on Solana are not announced. Read them with [`GET /v1/wallet/deposits/{id}`](/api/wallet-api/endpoints#get-a-deposit). ## Envelope ```json { "eventId": "wallet.deposit.updated:5e9d0273-...:COMPLETED:COMPLETED", "event": "wallet.deposit.updated", "webhookType": "WALLET", "timestamp": "2026-08-07T09:34:22.000Z", "data": {} } ``` | Field | Description | |-------|-------------| | `eventId` | Unique — use for idempotency | | `event` | Event name | | `webhookType` | `WALLET` for these events | | `timestamp` | ISO 8601 | | `data` | Event-specific payload | A retried deposit produces a second event for the same deposit, with its own `eventId`. Key your handler on `eventId` and treat a repeat as a no-op. ## Event data ### `wallet.deposit.updated` ```json { "id": "5e9d0273-8a41-4c62-b0f7-1d3e8c95a460", "txHash": "0x5d5355...103ee3", "logIndex": 12, "chainId": 1, "tokenId": "a71c4e08-2f96-4b3d-85ae-6c0f7d21b943", "amount": "100000", "from": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "status": "COMPLETED", "confirmations": 24, "triggerAddress": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "destination": { "type": "BANK_ACCOUNT", "id": "b21f4c77-...", "name": "ACME SDN BHD" }, "result": { "status": "FAILED", "statusReason": "sender_not_allowed", "withdrawal": null }, "createdAt": "2026-08-07T09:30:44.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` Fields match the REST record — see [`GET /v1/wallet/deposits/{id}`](/api/wallet-api/endpoints#get-a-deposit) for the full table and the [status reason values](/api/wallet-api/endpoints#deposit-status-reason-values). Read `result.status` for the outcome. `result` is `null` when the deposit arrived at an ordinary deposit address. ### `wallet.withdrawal.updated` ```json { "withdrawalId": "c4a80f13-6d29-4e75-83b1-9f0c2a7e5d68", "status": "COMPLETED", "statusReason": null, "amount": "100000", "refId": "FW-20260807-0001", "reference": "ACME payout", "destination": { "type": "BANK_ACCOUNT", "id": "b21f4c77-...", "name": "ACME SDN BHD" }, "depositId": "5e9d0273-8a41-4c62-b0f7-1d3e8c95a460", "createdAt": "2026-08-07T09:31:05.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` | Field | Type | Description | |-------|------|-------------| | `withdrawalId` | string (UUID) | Matches [`GET /v1/wallet/fiat/withdrawals/{id}`](/api/wallet-api/endpoints#get-fiat-withdrawal) | | `status` | string | `PENDING`, `PROCESSING`, `COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED` | | `statusReason` | string | null | Set only on `REJECTED`, `FAILED`, and `CANCELLED` | | `amount` | string | Amount in **sen** | | `refId` | string | Bank reference | | `reference` | string | null | Your own reference | | `destination` | object | null | The bank account paid | | `depositId` | string | null | The deposit that triggered it, or `null` for a withdrawal you created yourself | `depositId` is how you join the two events. It is the only field that distinguishes a triggered withdrawal from one you created with [`POST /v1/wallet/fiat/withdrawals`](/api/wallet-api/endpoints#create-fiat-withdrawal). # Onramp API Accept MYR from a customer and settle MYRC to an EVM or Solana address. This guide follows the integration sequence from account setup to final checkout reconciliation. ## Prerequisites Before you integrate, prepare: | Requirement | How to get it | |-------------|---------------| | Onramp API access | Ask BLOX to enable Onramp on your sandbox account | | Access per checkout type | Each type is enabled separately — holding one never grants another | | Checkout prefund balance | Only if you use `CHARGE_TO_PREFUND`; fund it under **Checkout → Top up prefund** | | API key and signer key id | Ask BLOX for API access | | Ed25519 key pair | Generate it yourself; register only the public key with BLOX | | Destination address | Use an EVM or Solana address you control | | Public HTTPS webhook URL | Register a `CHECKOUT` webhook under **Devtools → Webhooks** | Use the sandbox base URL while integrating: ```text https://api.sandbox.blox.my ``` Production uses `https://api.blox.my`. Read requests require `blox-api-key`; create requests also require an [HTTP message signature](/api/authentication/signature) and a persisted [`Idempotency-Key`](/api/idempotency). ## Choose a checkout type :::warning[Breaking change] There is now **one create endpoint**, and `type` is **required with no default**. Every existing caller gets a `400` until it is added — including callers that only ever wanted the Blox-account checkout they already have, who must send `"type": "BLOX_ACCOUNT"` for no change in behaviour. `POST /v1/checkout/direct` is **removed** — send `"type": "FPX_DIRECT"` to `POST /v1/checkout` instead. `GET /v1/checkout/direct/banks` is now `GET /v1/checkout/banks`. `type` was not defaulted on purpose. Defaulting to `FPX_HOSTED` would have silently switched unchanged callers to a different product, with their payers asked for a bank instead of a Blox login, and possibly a fee attached. ::: All three collect MYR from the customer and settle MYRC to `addressTo`. They differ in how the customer pays and who collects the bank details. Every one is hosted by BLOX at `checkout.blox.my` — the customer is always sent to a `checkoutUrl`. | `type` | Use it when | Fee | |--------|-------------|-----| | `BLOX_ACCOUNT` | The customer has a Blox account and pays with their own MYRC | Never charged; `feeMode` is **rejected** | | `FPX_HOSTED` | You want the customer to pick their bank on the BLOX page | `feeMode` required | | `FPX_DIRECT` | Your interface already collects the buyer name and FPX bank | `feeMode` required | Each type is a separate access grant. Being enabled for one does not enable another: the payer flows differ, and granting `FPX_HOSTED` because you hold `FPX_DIRECT` would put your customers in front of a page you never chose to use. ## How it works ```mermaid sequenceDiagram participant Customer participant App as Your App participant Backend as Your Backend participant API as BLOX Onramp API participant Checkout as BLOX Checkout Customer->>App: Start payment App->>Backend: Create checkout alt BLOX_ACCOUNT or FPX_HOSTED Backend->>API: POST /v1/checkout else FPX_DIRECT Backend->>API: GET /v1/checkout/banks Backend->>API: POST /v1/checkout end API-->>Backend: checkoutId, checkoutUrl, expiresAt Backend-->>App: checkoutUrl App->>Checkout: Open complete checkoutUrl Customer->>Checkout: Complete payment API-->>Backend: checkout.updated Checkout-->>App: Redirect to redirectUrl Backend->>API: GET /v1/checkout/{id} when reconciliation is needed ``` The browser redirect is for customer navigation, not payment confirmation. Fulfill only after a webhook or read response reports `COMPLETED`. ## Lifecycle ```text CREATED ──► PENDING ──► PROCESSING ──► COMPLETED │ │ │ │ │ ├──────────► FAILED │ │ └──────────► REJECTED ├────────────┴───────────────────────► CANCELLED └────────────────────────────────────► FAILED (expired after 20 min) ``` | Status | Meaning | Final | |--------|---------|-------| | `CREATED` | Link generated; the customer has not started | No | | `PENDING` | Customer is in progress, or an FPX bill was created | No | | `PROCESSING` | Payment initiated; confirmation is pending | No | | `COMPLETED` | MYRC transferred to `addressTo` | Yes | | `FAILED` | Payment failed, or the link expired after 20 minutes | Yes | | `CANCELLED` | Customer cancelled the checkout | Yes | | `REJECTED` | Payment provider refused the payment | Yes | `checkout.updated` is sent for `COMPLETED` and `FAILED`. Reconcile `CANCELLED` and `REJECTED` through `GET /v1/checkout/{id}`. Amounts are in **sen**: integers in create requests and strings in checkout responses. The accepted range is `1000`–`100000000` sen (RM10–RM1,000,000). ## Fees | `type` | Fee | `feeMode` | |--------|-----|-----------| | `BLOX_ACCOUNT` | Always `"0"`; `netAmount` equals `amount` | **Rejected** — there is no MYR leg to charge against, and silently ignoring the field is how you end up believing a fee applies | | `FPX_HOSTED` | Charged only when BLOX configured a checkout fee for the account | Required | | `FPX_DIRECT` | Charged only when BLOX configured a checkout fee for the account | Required | For both FPX types: * `DEDUCT_FROM_AMOUNT`: settlement is `amount - fee`. * `CHARGE_TO_PREFUND`: settlement is the full `amount` and the fee comes from your **checkout prefund** — a separate balance from your payout prefund, which cannot cover it. :::warning[`CHARGE_TO_PREFUND` is reserved at creation] The fee is taken out of your checkout prefund when the **link is created**, not when it is paid, and held until the checkout settles or the link ends. If the balance cannot cover it, `POST /v1/checkout` returns `400 INSUFFICIENT_BALANCE` and no link is created. There is **no fallback to deducting from the amount**. An earlier version silently switched a short-prefund checkout to `DEDUCT_FROM_AMOUNT`, so your customer paid the same and your settlement quietly arrived smaller than the mode you asked for. Failing at creation is loud, happens before you have shown the customer anything, and keeps `feeMode` meaning what you sent. If a link expires, is cancelled, or fails, the reserved fee returns to your prefund automatically. ::: Read `fee` and `netAmount` from the checkout instead of calculating settlement yourself. ## Step-by-step guide ### Step 1: Configure access Generate an Ed25519 key pair and send only the public key to BLOX: ```bash openssl genpkey -algorithm Ed25519 -out blox_private_key.pem openssl pkey -in blox_private_key.pem -pubout -out blox_public_key.pem ``` Keep the private key on your backend. Register a `CHECKOUT` webhook and copy the signing helper for your language from [Request Signing](/api/authentication/signature). ### Step 2: Resolve the settlement token Call `GET /v1/wallet/networks`, choose the network matching the format of `addressTo`, and select its MYRC `tokenId`. Do not hard-code the token ID across environments. Resolve it again when switching from sandbox to production. ### Step 3: Create the checkout Generate and persist one UUID v4 `Idempotency-Key` before sending. Put your order identifier in `title`, then choose one path: #### Blox-account checkout Send `type: "BLOX_ACCOUNT"` plus `addressTo`, `amount`, `tokenId`, `redirectUrl`, `title`, and optional `description` to [`POST /v1/checkout`](/api/onramp-api/endpoints#create-checkout). The customer signs in to their Blox account on the BLOX page and pays with their own MYRC. Do not send `feeMode` — it is rejected. ```javascript import { randomUUID } from "node:crypto"; const path = "/v1/checkout"; const body = { type: "BLOX_ACCOUNT", // required; no default addressTo: process.env.DESTINATION_ADDRESS, amount: 15000, // RM150.00 tokenId, redirectUrl: "https://example.com/orders/123/complete", title: "Order #123", }; const idempotencyKey = randomUUID(); // persist before sending const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const checkout = await response.json(); ``` ```python import os import uuid import requests path = "/v1/checkout" body = { "type": "BLOX_ACCOUNT", # required; no default "addressTo": os.environ["DESTINATION_ADDRESS"], "amount": 15000, # RM150.00 "tokenId": token_id, "redirectUrl": "https://example.com/orders/123/complete", "title": "Order #123", } idempotency_key = str(uuid.uuid4()) # persist before sending headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10, ) response.raise_for_status() checkout = response.json() ``` #### Hosted FPX checkout Send `type: "FPX_HOSTED"` plus the same fields and `feeMode`. Do **not** send `buyerName`, `bank`, or `bankType` — the customer supplies those on the BLOX page, which is also when the FPX bill is created, because the bank is part of what the bill is. This is the FPX flow to reach for unless you have a reason to collect bank details yourself: BLOX renders the bank picker, and you send the customer the returned `checkoutUrl`. #### Direct FPX checkout First fetch the active bank list from [`GET /v1/checkout/banks`](/api/onramp-api/endpoints#list-checkout-banks). Then send `type: "FPX_DIRECT"` plus `buyerName`, `bank`, `bankType`, and `feeMode` to [`POST /v1/checkout`](/api/onramp-api/endpoints#create-checkout). BLOX creates the FPX bill immediately, using the bank details your application collected. ```javascript import { randomUUID } from "node:crypto"; const banksResponse = await fetch( "https://api.sandbox.blox.my/v1/checkout/banks", { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!banksResponse.ok) throw new Error(await banksResponse.text()); const bank = (await banksResponse.json()).banks.find((item) => item.active); const path = "/v1/checkout"; const idempotencyKey = randomUUID(); // persist before sending const body = { type: "FPX_DIRECT", addressTo: process.env.DESTINATION_ADDRESS, amount: 15000, tokenId, redirectUrl: "https://example.com/orders/123/complete", title: "Order #123", buyerName: "Ada Lovelace", bank: bank.code, bankType: bank.type, feeMode: "DEDUCT_FROM_AMOUNT", }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const checkout = await response.json(); ``` ```python import os import uuid import requests banks_response = requests.get( "https://api.sandbox.blox.my/v1/checkout/banks", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) banks_response.raise_for_status() bank = next(item for item in banks_response.json()["banks"] if item["active"]) path = "/v1/checkout" idempotency_key = str(uuid.uuid4()) # persist before sending body = { "type": "FPX_DIRECT", "addressTo": os.environ["DESTINATION_ADDRESS"], "amount": 15000, "tokenId": token_id, "redirectUrl": "https://example.com/orders/123/complete", "title": "Order #123", "buyerName": "Ada Lovelace", "bank": bank["code"], "bankType": bank["type"], "feeMode": "DEDUCT_FROM_AMOUNT", } headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10, ) response.raise_for_status() checkout = response.json() ``` Every type returns the same shape: ```json { "checkoutId": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a", "checkoutUrl": "https://checkout.blox.my/8f14e45f-ceea-467f-a830-5e3e3c7e2b8a?s=checkout_signature", "expiresAt": "2026-02-03T15:20:00.000Z" } ``` The path inside `checkoutUrl` differs by type. Never construct it yourself — use the string BLOX returns, including its query string. ### Step 4: Send the customer to checkout Store `checkoutId` against your order and open the complete `checkoutUrl`, including its query string. Create the checkout only when the customer is ready; the link expires after 20 minutes. ### Step 5: Handle the final outcome Use [Webhook Events](/api/onramp-api/webhooks) as the primary status signal. Process each `eventId` once and make fulfillment idempotent on `checkoutId`. Use [`GET /v1/checkout/{id}`](/api/onramp-api/endpoints#get-checkout) only for reconciliation or to confirm `CANCELLED` and `REJECTED`. Credit the customer against `netAmount`, not `amount`. ## Production readiness Before switching to production: 1. Complete each applicable flow in [Sandbox Testing](/api/onramp-api/sandbox). 2. Confirm every create call sends `type`, and that nothing still calls `POST /v1/checkout/direct` or `GET /v1/checkout/direct/banks`. 3. Change the base URL and credentials. 4. Resolve the production `tokenId` again. 5. Register and verify the production webhook. 6. Confirm access for each checkout type you use, plus fees and limits, with BLOX. 7. If you use `CHARGE_TO_PREFUND`, fund the production checkout prefund and alert on `INSUFFICIENT_BALANCE` — it fails link creation, not payment. 8. Review [Onramp Errors](/api/onramp-api/errors) and alert on unresolved final outcomes. # Onramp API Reference All Onramp endpoints in integration order. Use `https://api.sandbox.blox.my` while testing and `https://api.blox.my` in production. Read requests require `blox-api-key`. Create requests require the API key, an [HTTP message signature](/api/authentication/signature), and an `Idempotency-Key` UUID v4 persisted before the first request. Every error uses `{ "code": "…", "message": "…", "requestId": "…" }`. Branch on `code`, never `message`; see [Onramp Errors](/api/onramp-api/errors) for retry guidance. | Method | Endpoint | Purpose | |--------|----------|---------| | `POST` | [`/v1/checkout`](#create-checkout) | Create a checkout of any type | | `GET` | [`/v1/checkout/banks`](#list-checkout-banks) | List FPX banks | | `GET` | [`/v1/checkout/{id}`](#get-checkout) | Read one checkout for reconciliation | | `GET` | [`/v1/checkout`](#list-checkouts) | List and filter checkouts | :::warning[Removed endpoints] `POST /v1/checkout/direct` is gone — send `"type": "FPX_DIRECT"` to `POST /v1/checkout` instead. `GET /v1/checkout/direct/banks` is now `GET /v1/checkout/banks`. ::: ## Create checkout Creates a checkout link of the type you name. The link expires after **20 minutes**, and the endpoint is limited to **120 requests per minute** per account. **Header** | Header | Required | Description | |--------|----------|-------------| | `Idempotency-Key` | Yes | UUID v4 persisted for this checkout before the first request | **Body — every type** | Field | Type | Required | Constraints | |-------|------|----------|-------------| | `type` | string | Yes | `BLOX_ACCOUNT`, `FPX_HOSTED`, or `FPX_DIRECT`. **No default** — omitting it is a `400` | | `addressTo` | string | Yes | EVM or Solana address | | `amount` | integer | Yes | `1000`–`100000000` sen (RM10–RM1,000,000) | | `tokenId` | string (UUID) | Yes | Active token from `GET /v1/wallet/networks` | | `redirectUrl` | string (URL) | Yes | Valid redirect after payment | | `title` | string | Yes | 1–100 characters | | `description` | string | No | Maximum 500 characters | **Additional fields by type** | Field | `BLOX_ACCOUNT` | `FPX_HOSTED` | `FPX_DIRECT` | |-------|----------------|--------------|--------------| | `feeMode` | **Rejected** | Required | Required | | `buyerName` | — | — | Required, 1–100 characters | | `bank` | — | — | Required, a `code` from `GET /v1/checkout/banks` | | `bankType` | — | — | Required: `RETAIL`/`CORPORATE`, or the wire codes `01`/`02` | `feeMode` is `DEDUCT_FROM_AMOUNT` or `CHARGE_TO_PREFUND`. Sending it on a `BLOX_ACCOUNT` checkout is a `400`, not an ignored field — that product has no MYR leg to charge against, and dropping it silently is how you end up believing a fee applies. `FPX_HOSTED` does not take `buyerName`, `bank`, or `bankType`: the customer supplies them on the BLOX page, which is also when the FPX bill is created. EVM addresses use `0x` plus 40 hexadecimal characters; Solana addresses use Base58. ```bash curl --fail-with-body -X POST "https://api.sandbox.blox.my/v1/checkout" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://example.com/orders/123/complete","title":"Order #123"}' ``` ```javascript const body = { type: "BLOX_ACCOUNT", addressTo: "0x742d35cc6634c0532925a3b844bc9e7595f8bd21", amount: 15000, tokenId: "550e8400-e29b-41d4-a716-446655440000", redirectUrl: "https://example.com/orders/123/complete", title: "Order #123", }; const headers = signBlox("POST", "/v1/checkout", body); const response = await fetch("https://api.sandbox.blox.my/v1/checkout", { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": process.env.IDEMPOTENCY_KEY, ...headers, }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python body = { "type": "BLOX_ACCOUNT", "addressTo": "0x742d35cc6634c0532925a3b844bc9e7595f8bd21", "amount": 15000, "tokenId": "550e8400-e29b-41d4-a716-446655440000", "redirectUrl": "https://example.com/orders/123/complete", "title": "Order #123", } headers = sign_blox("POST", "/v1/checkout", body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"], }) response = requests.post( "https://api.sandbox.blox.my/v1/checkout", json=body, headers=headers, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://example.com/orders/123/complete","title":"Order #123"}`) var body any json.Unmarshal(raw, &body) headers, _ := signBlox("POST", "/v1/checkout", body) req, _ := http.NewRequest( "POST", "https://api.sandbox.blox.my/v1/checkout", bytes.NewReader(raw), ) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", os.Getenv("IDEMPOTENCY_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() if response.StatusCode >= 300 { log.Fatal(response.Status) } ``` ```java String body = """ {"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://example.com/orders/123/complete","title":"Order #123"} """; var headers = signBlox("POST", "/v1/checkout", body); var builder = HttpRequest.newBuilder( URI.create("https://api.sandbox.blox.my/v1/checkout")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", System.getenv("IDEMPOTENCY_KEY")) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); if (response.statusCode() >= 300) throw new RuntimeException(response.body()); ``` ```csharp var body = """ {"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://example.com/orders/123/complete","title":"Order #123"} """; var headers = SignBlox("POST", "/v1/checkout", body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my/v1/checkout" ); request.Headers.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); request.Headers.Add( "Idempotency-Key", Environment.GetEnvironmentVariable("IDEMPOTENCY_KEY") ); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); response.EnsureSuccessStatusCode(); ``` **Response — `200`** ```json { "checkoutId": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a", "checkoutUrl": "https://checkout.blox.my/8f14e45f-ceea-467f-a830-5e3e3c7e2b8a?s=checkout_signature", "expiresAt": "2026-02-03T15:20:00.000Z" } ``` Use the complete `checkoutUrl`, including its query string. Unpaid checkouts become `FAILED` after expiry. A `202` response means the original request for that key is still processing; sign a fresh request and retry with the **same** key. **Idempotency rules** * Persist one UUID v4 before sending a logical checkout. * Retry `202` or `429` with the same key. * Retry a rejected `400` or `422` with a new key after correcting the request. * After a timeout or `5xx`, reconcile by your unique `title` before retrying with the same key. * Reusing a key with a different body returns `422 VALIDATION_FAILED`. See [Onramp Errors](/api/onramp-api/errors) for the complete code and retry table.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | `Idempotency-Key` is missing or malformed, or `tokenId` is unavailable | | `400` | `VALIDATION_FAILED` | A request field is invalid | | `401` | `UNAUTHORIZED` | API key or request signature is missing or invalid | | `403` | `FEATURE_DISABLED` | Checkout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `403` | `ACCOUNT_FROZEN` | The account is frozen | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `403` | `ACCOUNT_SUSPENDED` | Deposits are suspended for the account | | `422` | `VALIDATION_FAILED` | The idempotency key was already used with a different body | | `429` | `RATE_LIMITED` | The route or account rate limit was exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Missing idempotency key", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 VALIDATION\_FAILED For a request with an empty `title`: ```json { "code": "VALIDATION_FAILED", "message": "title: Title is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 FEATURE\_DISABLED ```json { "code": "FEATURE_DISABLED", "message": "Merchant checkout feature not enabled: checkout_with_blox_account", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_TERMINATED ```json { "code": "ACCOUNT_TERMINATED", "message": "Your account has been terminated. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_FROZEN ```json { "code": "ACCOUNT_FROZEN", "message": "Your account has been frozen. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_PENDING\_VERIFICATION ```json { "code": "ACCOUNT_PENDING_VERIFICATION", "message": "Your account is pending verification. This action is not allowed for this account at this time.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_SUSPENDED ```json { "code": "ACCOUNT_SUSPENDED", "message": "Your account is suspended. Deposits are not allowed for this account.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
422 VALIDATION\_FAILED ```json { "code": "VALIDATION_FAILED", "message": "Idempotency key used with different request payload", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
429 RATE\_LIMITED ```json { "code": "RATE_LIMITED", "message": "Too many checkout creations. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## List checkout banks ```bash curl --fail-with-body "https://api.blox.my/v1/checkout/banks" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( "https://api.blox.my/v1/checkout/banks", { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/checkout/banks", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/checkout/banks", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/checkout/banks")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/checkout/banks" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "banks": [ { "code": "MBBEMYKL", "name": "Maybank", "displayName": "Maybank", "logoUrl": "https://…", "type": "01", "active": true } ] } ``` | Field | Type | Description | |-------|------|-------------| | `code` | string | Bank code; pass as `bank` on create | | `name` | string | Bank name | | `displayName` | string | Customer-facing bank name | | `logoUrl` | string (URL) | Bank logo | | `type` | string | `"01"` for retail (B2C), or `"02"` for corporate (B2B); pass it as `bankType` | | `active` | boolean | Only use banks where this is `true` | Response is cached (~1 hour).
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | The bank provider could not return its bank list | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Direct Checkout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Failed to get banks", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create checkout — direct FPX The same [`POST /v1/checkout`](#create-checkout) endpoint, with `"type": "FPX_DIRECT"` and a bank selected from [`GET /v1/checkout/banks`](#list-checkout-banks). Shown separately because it is the only type that carries bank fields. For `"type": "FPX_HOSTED"`, send the body from [Create checkout](#create-checkout) plus `feeMode` and **omit** `buyerName`, `bank`, and `bankType` — the customer supplies those on the BLOX page, which is also when the FPX bill is created. **Body** | Field | Type | Required | Constraints | |-------|------|----------|-------------| | `type` | string | Yes | `FPX_DIRECT` | | `addressTo` | string | Yes | EVM or Solana address | | `amount` | integer | Yes | `1000`–`100000000` sen (RM10–RM1,000,000) | | `tokenId` | string (UUID) | Yes | Active token from `GET /v1/wallet/networks` | | `redirectUrl` | string (URL) | Yes | Valid redirect after payment | | `title` | string | Yes | 1–100 characters; use your reconciliation handle | | `description` | string | No | Maximum 500 characters | | `buyerName` | string | Yes | 1–100 trimmed characters | | `bank` | string | Yes | Active `code` from the banks list | | `bankType` | string | Yes | `RETAIL`, `CORPORATE`, `"01"`, or `"02"` | | `feeMode` | string | Yes | `DEDUCT_FROM_AMOUNT` or `CHARGE_TO_PREFUND` | `feeMode` is required on both FPX types, and is decided per checkout rather than per account: * **`DEDUCT_FROM_AMOUNT`** — your on-chain settlement is net of the fee. * **`CHARGE_TO_PREFUND`** — settlement is gross; the fee comes out of your **checkout prefund** balance instead. It is reserved the moment the link is created, not when the buyer pays, and if the balance cannot cover it the create call returns `400 INSUFFICIENT_BALANCE` and no link exists. :::warning[No fallback to `DEDUCT_FROM_AMOUNT`] An earlier version silently switched a short-prefund checkout to `DEDUCT_FROM_AMOUNT` at payment time, so your buyer paid the same and your settlement quietly arrived smaller than the mode you asked for. That fallback is gone. `feeMode` now means exactly what you sent, and a shortfall fails at creation — before you have shown the buyer anything. A reserved fee returns to your prefund automatically if the link expires, is cancelled, or fails. ::: It is not defaulted because it decides what reaches your address. If your account has no fee configured, the mode has no effect but is still required. ```bash curl --fail-with-body -X POST "https://api.sandbox.blox.my/v1/checkout" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://yoursite.com/order/123/complete","title":"Order #123","buyerName":"Ada Lovelace","bank":"MBBEMYKL","bankType":"01","feeMode":"DEDUCT_FROM_AMOUNT"}' ``` ```javascript const body = { type: "FPX_DIRECT", addressTo: "0x742d35cc6634c0532925a3b844bc9e7595f8bd21", amount: 15000, tokenId: "550e8400-e29b-41d4-a716-446655440000", redirectUrl: "https://yoursite.com/order/123/complete", title: "Order #123", buyerName: "Ada Lovelace", bank: "MBBEMYKL", bankType: "01", feeMode: "DEDUCT_FROM_AMOUNT", }; const headers = signBlox("POST", "/v1/checkout", body); const response = await fetch( "https://api.sandbox.blox.my/v1/checkout", { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": process.env.IDEMPOTENCY_KEY, ...headers, }, body: JSON.stringify(body), }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python body = { "type": "FPX_DIRECT", "addressTo": "0x742d35cc6634c0532925a3b844bc9e7595f8bd21", "amount": 15000, "tokenId": "550e8400-e29b-41d4-a716-446655440000", "redirectUrl": "https://yoursite.com/order/123/complete", "title": "Order #123", "buyerName": "Ada Lovelace", "bank": "MBBEMYKL", "bankType": "01", "feeMode": "DEDUCT_FROM_AMOUNT", } headers = sign_blox("POST", "/v1/checkout", body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"], }) response = requests.post( "https://api.sandbox.blox.my/v1/checkout", json=body, headers=headers, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://yoursite.com/order/123/complete","title":"Order #123","buyerName":"Ada Lovelace","bank":"MBBEMYKL","bankType":"01","feeMode":"DEDUCT_FROM_AMOUNT"}`) var body any json.Unmarshal(raw, &body) headers, _ := signBlox("POST", "/v1/checkout", body) req, _ := http.NewRequest( "POST", "https://api.sandbox.blox.my/v1/checkout", bytes.NewReader(raw), ) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", os.Getenv("IDEMPOTENCY_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() if response.StatusCode >= 300 { log.Fatal(response.Status) } ``` ```java String body = """ {"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://yoursite.com/order/123/complete","title":"Order #123","buyerName":"Ada Lovelace","bank":"MBBEMYKL","bankType":"01","feeMode":"DEDUCT_FROM_AMOUNT"} """; var headers = signBlox("POST", "/v1/checkout", body); var builder = HttpRequest.newBuilder( URI.create("https://api.sandbox.blox.my/v1/checkout")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", System.getenv("IDEMPOTENCY_KEY")) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); if (response.statusCode() >= 300) throw new RuntimeException(response.body()); ``` ```csharp var body = """ {"type":"BLOX_ACCOUNT","addressTo":"0x742d35cc6634c0532925a3b844bc9e7595f8bd21","amount":15000,"tokenId":"550e8400-e29b-41d4-a716-446655440000","redirectUrl":"https://yoursite.com/order/123/complete","title":"Order #123","buyerName":"Ada Lovelace","bank":"MBBEMYKL","bankType":"01","feeMode":"DEDUCT_FROM_AMOUNT"} """; var headers = SignBlox("POST", "/v1/checkout", body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my/v1/checkout" ); request.Headers.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); request.Headers.Add( "Idempotency-Key", Environment.GetEnvironmentVariable("IDEMPOTENCY_KEY") ); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); response.EnsureSuccessStatusCode(); ``` **Response — `200`** ```json { "checkoutId": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a", "checkoutUrl": "https://checkout.blox.my/fpx/8f14e45f-ceea-467f-a830-5e3e3c7e2b8a?s=checkout_signature", "expiresAt": "2026-02-03T15:20:00.000Z" } ``` Use the complete `checkoutUrl`, including its query string. The link expires after **20 minutes**. A `202` response means the original request for that key is still processing; sign a fresh request and retry with the **same** key. The buyer details supplied on create (`buyerName`, `bank`, `bankType`) are not echoed by reads; store them against `checkoutId`. Handle [Checkout webhook events](/api/onramp-api/webhooks) and use [`GET /v1/checkout/{id}`](#get-checkout) for reconciliation. **Idempotency rules** * Persist one UUID v4 before sending a logical checkout. * Retry `202` or `429` with the same key. * Retry a rejected `400` or `422` with a new key after correcting the request. * After a timeout or `5xx`, reconcile by your unique `title` before retrying with the same key. * Reusing a key with a different body returns `422 VALIDATION_FAILED`. See [Onramp Errors](/api/onramp-api/errors) for the complete code and retry table.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | `Idempotency-Key` is missing or malformed, `tokenId` is unavailable, or the amount cannot cover the fee | | `400` | `VALIDATION_FAILED` | A request field is invalid | | `401` | `UNAUTHORIZED` | API key or request signature is missing or invalid | | `403` | `FEATURE_DISABLED` | Direct Checkout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `403` | `ACCOUNT_FROZEN` | The account is frozen | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `403` | `ACCOUNT_SUSPENDED` | Deposits are suspended for the account | | `422` | `VALIDATION_FAILED` | The idempotency key was already used with a different body | | `429` | `RATE_LIMITED` | The route or account rate limit was exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Missing idempotency key", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 VALIDATION\_FAILED For a request with an empty `buyerName`: ```json { "code": "VALIDATION_FAILED", "message": "buyerName: Buyer name is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 FEATURE\_DISABLED ```json { "code": "FEATURE_DISABLED", "message": "Merchant checkout feature not enabled: checkout_direct", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_TERMINATED ```json { "code": "ACCOUNT_TERMINATED", "message": "Your account has been terminated. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_FROZEN ```json { "code": "ACCOUNT_FROZEN", "message": "Your account has been frozen. Please contact support.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_PENDING\_VERIFICATION ```json { "code": "ACCOUNT_PENDING_VERIFICATION", "message": "Your account is pending verification. This action is not allowed for this account at this time.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_SUSPENDED ```json { "code": "ACCOUNT_SUSPENDED", "message": "Your account is suspended. Deposits are not allowed for this account.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
422 VALIDATION\_FAILED ```json { "code": "VALIDATION_FAILED", "message": "Idempotency key used with different request payload", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
429 RATE\_LIMITED ```json { "code": "RATE_LIMITED", "message": "Too many checkout creations. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get checkout Returns a checkout by `checkoutId`. Use webhooks for status changes; use this endpoint for reconciliation or missed webhook recovery. | Path parameter | Type | Description | |----------------|------|-------------| | `id` | string (UUID) | Checkout owned by your account | ```bash curl "https://api.blox.my/v1/checkout/8f14e45f-ceea-467f-a830-5e3e3c7e2b8a" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const checkoutId = "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a"; const response = await fetch( `https://api.blox.my/v1/checkout/${checkoutId}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python checkout_id = "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a" response = requests.get( f"https://api.blox.my/v1/checkout/{checkout_id}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go checkoutID := "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a" req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/checkout/"+checkoutID, nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java String checkoutId = "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a"; var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/checkout/" + checkoutId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp var checkoutId = "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a"; using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( $"https://api.blox.my/v1/checkout/{checkoutId}" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`:** [Checkout object](#checkout-object). Unknown and cross-account IDs return `404 NOT_FOUND`.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | The checkout does not exist or belongs to another account | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
*** ## List checkouts Query all checkouts for your account with pagination and filtering. **Query parameters** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `limit` | integer | No | 1–100, default `25` | Records per page | | `cursor` | string | No | `checkoutId` of the last item from the previous page | Omit for the first page | | `status` | string | No | Checkout status | Filter by status | | `startDt` | string | No | ISO 8601 datetime | Created on or after | | `endDt` | string | No | ISO 8601 datetime | Created on or before | **Status filter values** | Status | Description | |--------|-------------| | `CREATED` | Checkout created, awaiting customer | | `PENDING` | Customer selected wallet, ready to pay | | `PROCESSING` | Payment initiated, waiting for confirmation | | `COMPLETED` | Payment successful | | `FAILED` | Payment failed | | `CANCELLED` | Checkout cancelled | | `REJECTED` | Payment provider refused the payment | **Request example** ```bash # Get recent checkouts curl "https://api.blox.my/v1/checkout?limit=10" \ -H "blox-api-key: $BLOX_API_KEY" # Filter by status curl "https://api.blox.my/v1/checkout?status=COMPLETED&limit=50" \ -H "blox-api-key: $BLOX_API_KEY" # Filter by date range curl "https://api.blox.my/v1/checkout?startDt=2026-01-01T00:00:00Z&endDt=2026-01-31T23:59:59Z" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const params = new URLSearchParams({ status: "COMPLETED", limit: "50", }); const response = await fetch( `https://api.blox.my/v1/checkout?${params}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/checkout", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, params={"status": "COMPLETED", "limit": 50}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/checkout?status=COMPLETED&limit=50", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create( "https://api.blox.my/v1/checkout?status=COMPLETED&limit=50")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/checkout?status=COMPLETED&limit=50" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** Each row has the [checkout object](#checkout-object) shape. ```json { "data": [ { "checkoutId": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a", "status": "COMPLETED", "amount": "15000", "fee": "0", "netAmount": "15000", "addressTo": "0x742d35Cc...", "tokenId": "550e8400-...", "token": { "id": "550e8400-...", "name": "MYRC", "symbol": "MYRC" }, "title": "Order #123", "description": null, "redirectUrl": "https://yoursite.com/success", "txHash": "0xabc123def456...", "paidAt": "2026-02-03T15:05:30.000Z", "expiresAt": "2026-02-03T15:20:00.000Z", "createdAt": "2026-02-03T15:00:00.000Z", "updatedAt": "2026-02-03T15:05:30.000Z" } ], "hasMore": true, "nextCursor": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a" } ``` **Response fields** | Field | Type | Description | |-------|------|-------------| | `data` | array | Checkouts, newest first | | `hasMore` | boolean | Another page exists | | `nextCursor` | string | null | Pass as `cursor` to fetch the next page; `null` on the last one | The list is **cursor paginated** — iterate until `hasMore` is false. There is no `total`.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | A query parameter is malformed or outside its constraint | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For `?limit=101`: ```json { "code": "VALIDATION_FAILED", "message": "limit: Number must be less than or equal to 100", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
*** ## Shared response schema ### Checkout object | Field | Type | Description | |-------|------|-------------| | `checkoutId` | string (UUID) | Unique checkout identifier; matches `checkoutId` on [webhook events](/api/onramp-api/webhooks) | | `type` | string | `BLOX_ACCOUNT`, `FPX_HOSTED`, or `FPX_DIRECT` — the same value you created with, so a mixed list can be told apart. Checkouts created before `type` existed read `BLOX_ACCOUNT`, which is what they are | | `status` | string | Current checkout status | | `amount` | string | Payment amount in sen | | `fee` | string | Fee in sen; always `"0"` for `BLOX_ACCOUNT` and when no checkout fee is configured | | `netAmount` | string | Amount settled on-chain. `amount - fee` under `DEDUCT_FROM_AMOUNT`, and equal to `amount` under `CHARGE_TO_PREFUND` | | `addressTo` | string | Address receiving MYRC | | `tokenId` | string (UUID) | Token delivered; uses IDs from `GET /v1/wallet/networks` | | `token` | object | `{ id, name, symbol }` | | `title` | string | Title supplied on create | | `description` | string | null | Description supplied on create | | `redirectUrl` | string | Redirect supplied on create | | `txHash` | string | null | On-chain transaction hash when available | | `paidAt` | string (ISO 8601) | null | Time the transfer confirmed | | `expiresAt` | string (ISO 8601) | Checkout expiration time | | `createdAt` / `updatedAt` | string (ISO 8601) | Creation and last update times | See [Fees](/api/onramp-api/overview#fees) for how `feeMode`, `fee`, and `netAmount` interact. *** ## Reconcile checkout status Use [checkout webhooks](/api/onramp-api/webhooks) as the primary signal. Read a checkout to reconcile a missed event or confirm a `CANCELLED` or `REJECTED` outcome, because those statuses do not emit `checkout.updated`. If polling is required: * Poll every 5 seconds while a checkout is `PROCESSING`. * Stop at `COMPLETED`, `FAILED`, `CANCELLED`, or `REJECTED`. The canonical status transitions and expiration rules are in [Onramp lifecycle](/api/onramp-api/overview#lifecycle). # Checkout Webhook Events Status notifications for your checkouts. Register a `CHECKOUT` endpoint from the dashboard (**Devtools → Webhooks**). Registration, signature verification, retries, and URL rules: [Webhooks](/api/webhooks). ## Events | Event | Fires when | |-------|------------| | `checkout.updated` | A checkout reaches `COMPLETED` or `FAILED` | `FAILED` covers both failure and expiry (20 minutes). `CANCELLED` and `REJECTED` do **not** fire; reconcile those outcomes with [Get Checkout](/api/onramp-api/endpoints#get-checkout). ## Payload ```json { "eventId": "checkout.updated:8f14e45f-ceea-467f-a830-5e3e3c7e2b8a:COMPLETED", "event": "checkout.updated", "webhookType": "CHECKOUT", "timestamp": "2026-02-03T15:05:30.000Z", "data": { "checkoutId": "8f14e45f-ceea-467f-a830-5e3e3c7e2b8a", "status": "COMPLETED", "statusReason": null, "amount": "15000", "fee": "0", "netAmount": "15000", "tokenId": "550e8400-e29b-41d4-a716-446655440000", "txHash": "0x5d53558791c9346d644d077354420f9a93600acf54eb806ecb9aad077c103ee3" } } ``` The outer fields are the [shared envelope](/api/webhooks#envelope). `data` carries: | Field | Type | Description | |-------|------|-------------| | `checkoutId` | string (UUID) | Matches `id` from [`GET /v1/checkout/{id}`](/api/onramp-api/endpoints#get-checkout) | | `status` | string | `COMPLETED` or `FAILED` | | `statusReason` | string | null | Stable key when `status` is `FAILED`, otherwise `null` | | `amount` | string | Amount in sen | | `fee` | string | Fee in sen. Always `"0"` on a `BLOX_ACCOUNT` checkout, and on an FPX checkout unless BLOX has configured a checkout fee for your account — see [Fees](/api/onramp-api/overview#fees) | | `netAmount` | string | What settles on-chain. **Credit the customer against this, not `amount`** — under `DEDUCT_FROM_AMOUNT` they differ, and a `CHARGE_TO_PREFUND` checkout falls back to deducting if your prefund cannot cover the fee | | `tokenId` | string (UUID) | Token delivered — same ids as `GET /v1/wallet/networks` | | `txHash` | string | null | The on-chain transfer, once one exists | Amounts are strings in sen, matching the REST response. ## Handling ```javascript // eventId is unique per (checkout, status) — a repeat is a redelivery. if (await seen(payload.eventId)) return respond(200); if (payload.data.status === "COMPLETED") { // fulfill must also be idempotent on checkoutId. await fulfill(payload.data.checkoutId, payload.data.netAmount); } await record(payload.eventId); return respond(200); ``` * Acknowledge with `2xx` within 10 seconds; do the work asynchronously * Key idempotency on `eventId` * Make fulfillment idempotent on `checkoutId` so a crash between fulfillment and recording the event cannot credit twice * Treat [`GET /v1/checkout/{id}`](/api/onramp-api/endpoints#get-checkout) as the source of truth for fulfillment # Onramp Errors Every error uses the shared merchant API envelope: ```json { "code": "VALIDATION_FAILED", "message": "…", "requestId": "…" } ``` **`code` is the contract.** Never parse `message`; it may be reworded. Quote `requestId` when reporting an unexpected failure to BLOX. Shared authentication and HTTP semantics: [Errors](/api/errors). ## Codes | Status | `code` | Meaning / action | |--------|--------|------------------| | `400` | `INVALID_REQUEST` | `Idempotency-Key` is missing or malformed, `tokenId` is unavailable, the bank list cannot be loaded, or an FPX checkout amount cannot cover its fee. Fix the request or retry the bank list. | | `400` | `VALIDATION_FAILED` | A body or query field is malformed or outside its constraint — including a missing `type`, or a `feeMode` sent on a `BLOX_ACCOUNT` checkout. Fix the request. | | `400` | `INSUFFICIENT_BALANCE` | `CHARGE_TO_PREFUND` was requested and your **checkout prefund** cannot cover the fee. No link was created. Top up under **Checkout → Top up prefund**, or send `DEDUCT_FROM_AMOUNT` instead. Retrying without doing one of those will fail identically. | | `401` | `UNAUTHORIZED` | API key or signature headers are missing or invalid. Re-sign the current body with a fresh `created` value. | | `403` | `FEATURE_DISABLED` | The checkout type you asked for is not enabled for the account — each is granted separately, so holding one never implies another. The message names the missing one. Contact BLOX. | | `403` | `ACCOUNT_FROZEN` / `ACCOUNT_SUSPENDED` / `ACCOUNT_PENDING_VERIFICATION` | The account state blocks money in. Contact BLOX. | | `403` | `ACCOUNT_TERMINATED` | The account is terminated; read and write routes are blocked. | | `404` | `NOT_FOUND` | The checkout does not exist or belongs to another account. | | `422` | `VALIDATION_FAILED` | The idempotency key was already used with a different body on the same route. | | `429` | `RATE_LIMITED` | A checkout-create route or account limit was exceeded. Back off before retrying. | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure. Reconcile first and report `requestId` if it persists. | ## Retry guidance Checkout creation needs one extra safeguard: a retry can create a second unpaid link. No money moves until a customer pays, but two active links can still cause duplicate payment. | Response | What to do | |----------|------------| | `202` | Wait briefly, sign a fresh request, and retry with the **same** `Idempotency-Key` | | `400`, `422` | Fix the request and use a **new** key; rejected outcomes are remembered for 24 hours | | `401`, `403` | Fix access or signing before retrying | | `429` | Back off and retry with the **same** key; the request did not run | | `5xx` or timeout | List checkouts and match your unique `title`; retry with the same key only when the original did not land | Persist the UUID v4 before sending and put your order identifier in `title`. Full idempotency behavior: [Idempotency](/api/idempotency). # Sandbox Testing Use sandbox to verify checkout creation, customer redirect, status handling, and webhook reconciliation before using production credentials. ## Base URL ```text https://api.sandbox.blox.my ``` Sandbox and production use different credentials and token IDs. Resolve `tokenId` from `GET /v1/wallet/networks` in each environment rather than hard-coding it. ## Before testing Prepare: * A sandbox API key with Onramp enabled * Each checkout type you plan to test enabled separately — holding one never grants another * A funded checkout prefund balance if you are testing `CHARGE_TO_PREFUND` * A registered signer public key and the matching private key * A destination EVM or Solana address you control * A `CHECKOUT` webhook endpoint under **Devtools → Webhooks** ## Blox-account checkout test 1. Resolve an MYRC `tokenId` whose network matches `addressTo`. 2. Send [`POST /v1/checkout`](/api/onramp-api/endpoints#create-checkout) with `"type": "BLOX_ACCOUNT"` and a persisted `Idempotency-Key`. 3. Store `checkoutId` and open the complete `checkoutUrl`, including its query string. 4. Sign in with a sandbox Blox account and complete payment. 5. Expect the checkout to progress through `CREATED`, `PENDING`, and `PROCESSING` to `COMPLETED`. 6. Confirm `checkout.updated`, then reconcile with [`GET /v1/checkout/{id}`](/api/onramp-api/endpoints#get-checkout). For `BLOX_ACCOUNT`, `fee` is `"0"` and `netAmount` equals `amount`. Sending `feeMode` should return `400`. ## Hosted FPX checkout test 1. Send [`POST /v1/checkout`](/api/onramp-api/endpoints#create-checkout) with `"type": "FPX_HOSTED"` and `feeMode`, and **no** bank fields. 2. Confirm the checkout is `CREATED` — no FPX bill exists yet, which is the difference from direct. 3. Open the returned `checkoutUrl`; the BLOX page should render a bank picker. 4. Pick a bank and submit; the checkout should move to `PENDING` and hand off to the sandbox FPX session. 5. Verify the same status and webhook flow as above. ## Direct FPX checkout test 1. Fetch [`GET /v1/checkout/banks`](/api/onramp-api/endpoints#list-checkout-banks). 2. Select an active bank and pass its `code` and `type` as `bank` and `bankType`. 3. Send [`POST /v1/checkout`](/api/onramp-api/endpoints#create-checkout) with `"type": "FPX_DIRECT"`, `buyerName`, and `feeMode`. 4. Confirm the checkout is already `PENDING` — the bill was created with the link. 5. Open the returned `checkoutUrl` and complete the sandbox FPX session. Test both fee modes on either FPX type if BLOX has configured a checkout fee on your sandbox account: * `DEDUCT_FROM_AMOUNT`: confirm `netAmount` is the amount settled after the fee. * `CHARGE_TO_PREFUND`: confirm your checkout prefund drops by the fee **at creation**, `netAmount` stays the full amount, and letting the link expire returns the fee to the prefund. * `CHARGE_TO_PREFUND` with an empty prefund: confirm the create call returns `400 INSUFFICIENT_BALANCE` and that no checkout was created. ## Expiration and cancellation * Leave a checkout unpaid for 20 minutes; it should become `FAILED` and emit `checkout.updated`. * Cancel from the checkout page; it should become `CANCELLED` without a webhook, so confirm it through the read endpoint. * Always stop fulfillment unless the authoritative checkout status is `COMPLETED`. ## Production switch Before going live: 1. Change the base URL to `https://api.blox.my` and use production credentials. 2. Resolve the production `tokenId` again. 3. Register the production webhook URL and secret. 4. Confirm access for each checkout type you use, plus fees and limits, with BLOX. 5. Fund the production checkout prefund if you use `CHARGE_TO_PREFUND`. ## Related * [Overview](/api/onramp-api/overview) * [API Reference](/api/onramp-api/endpoints) * [Webhook Events](/api/onramp-api/webhooks) * [Errors](/api/onramp-api/errors) # Payout API Send Malaysian bank payouts from a prefunded BLOX balance. This guide follows the production integration sequence from account setup to reconciliation. ## Prerequisites Before you integrate, prepare: | Requirement | How to get it | |-------------|---------------| | Payout API access | Ask BLOX to enable Payout on your sandbox account | | API key and signer key id | Ask BLOX for API access | | Ed25519 key pair | Generate it yourself; register only the public key with BLOX | | Public HTTPS webhook URL | Register a `PAYOUT` webhook under **Devtools → Webhooks** | | Prefund balance | Create a top-up under **Payouts → Top up** | Use the sandbox base URL while integrating: ```text https://api.sandbox.blox.my ``` Production uses `https://api.blox.my`. All read requests require `blox-api-key`; all write requests also require an [HTTP message signature](/api/authentication/signature). Payout signatures accept a `created=` timestamp within ±300 seconds. ## How it works ```mermaid sequenceDiagram participant Dashboard as BLOX Dashboard participant Backend as Your Backend participant API as BLOX Payout API participant Bank as Bank Rails Dashboard->>API: Top up prefund API-->>Backend: payout.prefund_completed Backend->>API: GET /v1/payout/prefund/balance Backend->>API: POST /v1/payouts API-->>Backend: Payout INITIATED API->>Bank: Submit transfer Bank-->>API: Final outcome API-->>Backend: payout.updated Backend->>API: GET /v1/payouts/{id} when reconciliation is needed ``` A payout moves through one of these paths: ```text INITIATED ─────► SETTLED ─────► RETURNED │ └──────────► REVERSED ``` | Status | Meaning | |--------|---------| | `INITIATED` | Accepted, debited from prefund, and queued for the bank | | `SETTLED` | The bank confirmed delivery | | `REVERSED` | Rejected before settlement; `netAmount` and the fee return | | `RETURNED` | Returned after settlement; `netAmount` returns and the fee is kept | Amounts are always in **sen**: integers in requests and strings in responses. Read `fee` and `netAmount` from the response instead of calculating them yourself. Every payout requires a `feeMode`: * `DEDUCT_FROM_AMOUNT`: the beneficiary receives `amount - fee`. * `CHARGE_TO_PREFUND`: the beneficiary receives the full `amount`; the fee is debited separately. In both cases, the prefund debit is `netAmount + fee`. BLOX configures the fee per account as a percentage, a fixed amount, and one formula. `MAX` uses the larger charge (`max(amount × rate, fixed)`); `SUM` adds them. The standard arrangement is `MAX` at 1.00% with a RM1.50 floor, but confirm your configuration with BLOX. The percentage is rounded half-up to the nearest sen before the formula is applied; the response remains authoritative. ## Step-by-step guide ### Step 1: Configure your application Generate an Ed25519 key pair and send only the public key to BLOX: ```bash openssl genpkey -algorithm Ed25519 -out blox_private_key.pem openssl pkey -in blox_private_key.pem -pubout -out blox_public_key.pem ``` Set your sandbox credentials. The examples below assume you have copied the Node.js `signBlox` or Python `sign_blox` helper from [Request Signing](/api/authentication/signature). ```javascript const BASE_URL = "https://api.sandbox.blox.my"; const API_KEY = process.env.BLOX_API_KEY; ``` ```python import os import requests BASE_URL = "https://api.sandbox.blox.my" API_KEY = os.environ["BLOX_API_KEY"] ``` Keep the private key on your server. Never send it to BLOX or expose it in frontend code. ### Step 2: Register your webhook and fund the prefund In the dashboard: 1. Register a public HTTPS endpoint of type `PAYOUT` under **Devtools → Webhooks**. 2. Create a sandbox top-up under **Payouts → Top up**. 3. Wait for `payout.prefund_completed`; sandbox top-ups normally complete in about 10 seconds. Verify webhook signatures and process each `eventId` once. See [Webhook Events](/api/payout-api/webhooks) for the payloads. ### Step 3: Check the available balance Read `available` before creating a payout. Do not subtract `inFlight`; it is already excluded. ```javascript const response = await fetch(`${BASE_URL}/v1/payout/prefund/balance`, { headers: { "blox-api-key": API_KEY }, }); if (!response.ok) throw new Error(await response.text()); const balance = await response.json(); console.log(balance.available); // string in sen ``` ```python response = requests.get( f"{BASE_URL}/v1/payout/prefund/balance", headers={"blox-api-key": API_KEY}, timeout=10, ) response.raise_for_status() balance = response.json() print(balance["available"]) # string in sen ``` See [`GET /v1/payout/prefund/balance`](/api/payout-api/endpoints#get-prefund-balance) for every response field. ### Step 4: Choose the beneficiary Fetch the active banks and use the returned `bankCode`. Then choose one payout path: * Send `beneficiary` inline for a new or one-off recipient. * Register the recipient first and send its `beneficiaryId` when you want to reuse it. ```javascript const response = await fetch(`${BASE_URL}/v1/payouts/active-banks`, { headers: { "blox-api-key": API_KEY }, }); if (!response.ok) throw new Error(await response.text()); const banks = await response.json(); const bankCode = banks[0].bankCode; ``` ```python response = requests.get( f"{BASE_URL}/v1/payouts/active-banks", headers={"blox-api-key": API_KEY}, timeout=10, ) response.raise_for_status() banks = response.json() bank_code = banks[0]["bankCode"] ``` See [Active Banks](/api/payout-api/endpoints#get-active-banks) and [Beneficiary endpoints](/api/payout-api/endpoints#create-beneficiary) for the complete fields. ### Step 5: Create the payout Generate and persist one UUID v4 `Idempotency-Key` **before** sending. Reuse the same key after a timeout or `5xx`; a new key can create another transfer. ```javascript import { randomUUID } from "node:crypto"; const path = "/v1/payouts"; const body = { amount: 100000, // RM1,000.00 feeMode: "DEDUCT_FROM_AMOUNT", beneficiary: { name: "Ada Lovelace", bankCode, accountNumber: "1234560000", }, reference: "INV-4471", }; const idempotencyKey = randomUUID(); // persist before the request const response = await fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const payout = await response.json(); console.log(payout.payoutId, payout.status, payout.netAmount, payout.fee); ``` ```python import uuid path = "/v1/payouts" body = { "amount": 100000, # RM1,000.00 "feeMode": "DEDUCT_FROM_AMOUNT", "beneficiary": { "name": "Ada Lovelace", "bankCode": bank_code, "accountNumber": "1234560000", }, "reference": "INV-4471", } idempotency_key = str(uuid.uuid4()) # persist before the request headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"{BASE_URL}{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() payout = response.json() print(payout["payoutId"], payout["status"], payout["netAmount"], payout["fee"]) ``` A successful create returns `INITIATED`. A `202` means the first request is still processing; retry with the same idempotency key. See [`POST /v1/payouts`](/api/payout-api/endpoints#create-payout) for validation, response fields, and retry rules. ### Step 6: Handle the final outcome Use `payout.updated` webhooks as the primary status signal. Store the `payoutId`, process webhook `eventId` values idempotently, and handle all three final statuses: `SETTLED`, `REVERSED`, and `RETURNED`. Use the read endpoint only to reconcile a missed webhook: ```javascript const response = await fetch(`${BASE_URL}/v1/payouts/${payout.payoutId}`, { headers: { "blox-api-key": API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( f"{BASE_URL}/v1/payouts/{payout['payoutId']}", headers={"blox-api-key": API_KEY}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ### Step 7 (optional): Pay a beneficiary from a deposit Ask BLOX to enable paying a beneficiary from a deposit, then give a beneficiary its own on-chain address. Tokens sent to that address are converted and paid to that beneficiary — no `POST /v1/payouts`, and no prefund balance involved. Set it up once per beneficiary: 1. `POST /v1/payouts/beneficiaries/{beneficiaryId}/address` returns the address. A beneficiary has one address for its lifetime, so calling again returns the same one. 2. `POST /v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist` for each address you will send from. The second step is not optional. **A trigger address accepts nothing until you allow a sender.** Deposits from any other address are refused with `sender_not_allowed`, and the tokens simply stay in your wallet. ```javascript const addressPath = `/v1/payouts/beneficiaries/${beneficiaryId}/address`; const addressResponse = await fetch(`${BASE_URL}${addressPath}`, { method: "POST", headers: { "blox-api-key": API_KEY, ...signBlox("POST", addressPath) }, }); if (!addressResponse.ok) throw new Error(await addressResponse.text()); const { address } = await addressResponse.json(); const allowPath = `${addressPath}/whitelist`; const allowBody = { address: treasuryAddress, label: "Treasury wallet" }; await fetch(`${BASE_URL}${allowPath}`, { method: "POST", headers: { "blox-api-key": API_KEY, "Content-Type": "application/json", ...signBlox("POST", allowPath, allowBody), }, body: JSON.stringify(allowBody), }); console.log(address); // send tokens here from treasuryAddress ``` ```python address_path = f"/v1/payouts/beneficiaries/{beneficiary_id}/address" headers = sign_blox("POST", address_path) headers["blox-api-key"] = API_KEY address_response = requests.post( f"{BASE_URL}{address_path}", headers=headers, timeout=10 ) address_response.raise_for_status() address = address_response.json()["address"] allow_path = f"{address_path}/whitelist" allow_body = {"address": treasury_address, "label": "Treasury wallet"} headers = sign_blox("POST", allow_path, allow_body) headers.update({"blox-api-key": API_KEY, "Content-Type": "application/json"}) requests.post( f"{BASE_URL}{allow_path}", json=allow_body, headers=headers, timeout=10 ).raise_for_status() print(address) # send tokens here from treasury_address ``` Transfers made this way are reported on [`payout.deposit.updated` and `payout.withdrawal.updated`](/api/payout-api/webhooks), not `payout.created` or `payout.updated`, and they do not appear in `GET /v1/payouts`. One deposit produces at most one transfer. If a deposit is refused for a reason you can fix, fix it and call [`POST /v1/payouts/deposits/{id}/redrive`](/api/payout-api/endpoints#retry-a-deposit). If you sent tokens and heard nothing at all, [`POST /v1/payouts/deposits/check-missed`](/api/payout-api/endpoints#check-for-a-missed-deposit) with the transaction hash is the way back. Before switching to production, test settlement, reversal, return, and pending outcomes using [Sandbox Testing](/api/payout-api/sandbox). Then change the base URL and credentials, fund the production prefund, confirm your fee and account limits with BLOX, and alert on `RETURNED` and `payout.prefund_reversed`. # Payout API Reference All Payout API endpoints in integration order. Use `https://api.sandbox.blox.my` while testing and `https://api.blox.my` in production. Read requests require `blox-api-key`. Write requests require the API key, an [HTTP message signature](/api/authentication/signature), and—where shown—an `Idempotency-Key` UUID v4. Every error uses `{ "code": "…", "message": "…", "requestId": "…" }`. Branch on `code`, never `message`; the tables under each endpoint list the expected status and code combinations. | Method | Endpoint | Purpose | |--------|----------|---------| | `GET` | [`/v1/payout/prefund/balance`](#get-prefund-balance) | Read spendable and pending balances | | `GET` | [`/v1/payouts/active-banks`](#get-active-banks) | Resolve a beneficiary bank code | | `POST` | [`/v1/payouts/beneficiaries`](#create-beneficiary) | Register a reusable beneficiary | | `GET` | [`/v1/payouts/beneficiaries`](#list-beneficiaries) | List active beneficiaries | | `GET` | [`/v1/payouts/beneficiaries/{beneficiaryId}`](#get-beneficiary) | Get one beneficiary | | `DELETE` | [`/v1/payouts/beneficiaries/{beneficiaryId}`](#delete-beneficiary) | Deactivate a beneficiary | | `POST` | [`/v1/payouts`](#create-payout) | Create and submit a payout | | `GET` | [`/v1/payouts/{id}`](#get-payout) | Read a payout for reconciliation | | `POST` | [`/v1/payouts/beneficiaries/{beneficiaryId}/address`](#create-a-deposit-trigger-address) | Give a beneficiary a deposit trigger address | | `GET` | [`/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist`](#list-allowed-senders) | List the senders allowed to trigger it | | `POST` | [`/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist`](#allow-a-sender) | Allow a sender | | `PATCH` | [`/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist/{senderId}`](#disable-or-re-enable-a-sender) | Pause a sender without losing it | | `DELETE` | [`/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist/{senderId}`](#remove-an-allowed-sender) | Stop allowing a sender | | `GET` | [`/v1/payouts/deposits/{id}`](#get-a-deposit) | Read a deposit and the transfer it triggered | | `POST` | [`/v1/payouts/deposits/check-missed`](#check-for-a-missed-deposit) | Have BLOX look at a transaction it never picked up | | `POST` | [`/v1/payouts/deposits/{id}/redrive`](#retry-a-deposit) | Retry a deposit that produced no transfer | ## Get prefund balance Returns the prefund balance. This endpoint takes no parameters and is limited to **60 requests per minute** per account. ```bash curl "https://api.blox.my/v1/payout/prefund/balance" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.blox.my/v1/payout/prefund/balance", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/payout/prefund/balance", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest("GET", "https://api.blox.my/v1/payout/prefund/balance", nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/payout/prefund/balance")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/payout/prefund/balance" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "available": "4900000", "inFlight": "99000", "receivable": "0" } ``` | Field | Type | Description | |-------|------|-------------| | `available` | string | Spendable amount in sen | | `inFlight` | string | `netAmount` of accepted payouts that are not final | | `receivable` | string | Normally `"0"`; a negative value is owed to BLOX after an uncovered prefund reversal | `available` already excludes `inFlight`; do not subtract it again. A negative `receivable` suspends payout routes with `403 FEATURE_DISABLED` until resolved. Use `payout.prefund_completed` instead of polling for top-ups.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get active banks Returns the banks and e-wallets that can receive payouts, with the bank codes accepted by beneficiary and payout requests. The endpoint is limited to **600 requests per minute** per account. ```bash curl "https://api.blox.my/v1/payouts/active-banks" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch("https://api.blox.my/v1/payouts/active-banks", { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/payouts/active-banks", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest("GET", "https://api.blox.my/v1/payouts/active-banks", nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/payouts/active-banks")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync("https://api.blox.my/v1/payouts/active-banks"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json [ { "name": "Maybank", "bankCode": "MBBEMYKL" }, { "name": "TNG Digital (TouchNGo)", "bankCode": "TNGDMYNB" } ] ``` | Field | Type | Description | |-------|------|-------------| | `name` | string | Bank or e-wallet name | | `bankCode` | string | The bank's BIC | Use `bankCode` wherever the API asks for `bankCode`.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create beneficiary Registers a reusable beneficiary. This endpoint requires an `Idempotency-Key` UUID v4 and is limited to **20 requests per minute** per account. | Field | Type | Required | Constraints | |-------|------|----------|-------------| | `name` | string | Yes | 2–96 characters | | `bankCode` | string | Yes | 1–11 characters; use a code from `/v1/payouts/active-banks` | | `accountNumber` | string | Yes | 3–20 digits | The name is trimmed and uppercased. Registration does not deduplicate matching details; search first if the beneficiary may already exist. The default active-beneficiary limit is 1,000. ```bash curl -X POST "https://api.sandbox.blox.my/v1/payouts/beneficiaries" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen | tr 'A-Z' 'a-z')" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"name":"Ada Lovelace","bankCode":"MBBM","accountNumber":"1234567890"}' ``` ```javascript const path = "/v1/payouts/beneficiaries"; const body = { name: "Ada Lovelace", bankCode: "MBBM", accountNumber: "1234567890", }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python path = "/v1/payouts/beneficiaries" body = { "name": "Ada Lovelace", "bankCode": "MBBM", "accountNumber": "1234567890", } headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4()), }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"name":"Ada Lovelace","bankCode":"MBBM","accountNumber":"1234567890"}`) var body any json.Unmarshal(raw, &body) path := "/v1/payouts/beneficiaries" headers, _ := signBlox("POST", path, body) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(raw)) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", uuid.NewString()) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/beneficiaries"; String body = """ {"name":"Ada Lovelace","bankCode":"MBBM","accountNumber":"1234567890"} """; var headers = signBlox("POST", path, body); var builder = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", UUID.randomUUID().toString()) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp const string path = "/v1/payouts/beneficiaries"; var body = """ {"name":"Ada Lovelace","bankCode":"MBBM","accountNumber":"1234567890"} """; var headers = SignBlox("POST", path, body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my" + path ); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); ``` **Response — `200`** ```json { "id": "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55", "name": "ADA LOVELACE", "accountNumber": "1234567890", "bank": { "code": "MBBM", "name": "Maybank" }, "autoWithdrawalAddress": null, "createdAt": "2026-07-16T09:30:00.000Z", "updatedAt": "2026-07-16T09:30:00.000Z" } ``` The response uses the shared [Beneficiary object](#beneficiary-object).
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | A body field is missing, malformed, or outside its constraint | | `400` | `INVALID_REQUEST` | `Idempotency-Key` is missing or malformed | | `400` | `UNKNOWN_BENEFICIARY` | `bankCode` is not in your `/v1/payouts/active-banks` list | | `401` | `UNAUTHORIZED` | API key or signature is missing or invalid | | `403` | `LIMIT_EXCEEDED` | The active-beneficiary cap is reached | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `422` | `VALIDATION_FAILED` | The idempotency key was already used with a different body | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For a request with `name: "A"`: ```json { "code": "VALIDATION_FAILED", "message": "name: Beneficiary name can't be less than 2 characters", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## List beneficiaries Returns active beneficiaries, newest first. | Query | Type | Description | |-------|------|-------------| | `limit` | integer | 1–100; default 25 | | `cursor` | string | Opaque `nextCursor` from the previous response | | `search` | string | Case-insensitive substring match on name, account number, bank name, or auto-withdrawal address | ```bash curl "https://api.blox.my/v1/payouts/beneficiaries?search=Ada&limit=20" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const url = new URL("https://api.blox.my/v1/payouts/beneficiaries"); url.search = new URLSearchParams({ search: "Ada", limit: "20" }); const response = await fetch(url, { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( "https://api.blox.my/v1/payouts/beneficiaries", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, params={"search": "Ada", "limit": 20}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/payouts/beneficiaries?search=Ada&limit=20", nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/payouts/beneficiaries?search=Ada&limit=20")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( "https://api.blox.my/v1/payouts/beneficiaries?search=Ada&limit=20" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`** ```json { "data": [], "hasMore": true, "nextCursor": "9f21ab04-…" } ``` Each `data` item is a [Beneficiary object](#beneficiary-object). Continue with `nextCursor` until `hasMore` is `false`; `nextCursor` is `null` on the last page.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | A query parameter is malformed or outside its constraint | | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `429` | `RATE_LIMITED` | The account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For `?limit=101`: ```json { "code": "VALIDATION_FAILED", "message": "limit: Number must be less than or equal to 100", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get beneficiary Returns one active beneficiary. Unknown, inactive, and cross-account IDs return `404 NOT_FOUND`. | Path parameter | Type | Description | |----------------|------|-------------| | `beneficiaryId` | string (UUID) | Beneficiary owned by your account | ```bash curl "https://api.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; const response = await fetch( `https://api.blox.my/v1/payouts/beneficiaries/${beneficiaryId}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python beneficiary_id = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55" response = requests.get( f"https://api.blox.my/v1/payouts/beneficiaries/{beneficiary_id}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go beneficiaryID := "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55" req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/payouts/beneficiaries/"+beneficiaryID, nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java String beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/payouts/beneficiaries/" + beneficiaryId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp var beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync( $"https://api.blox.my/v1/payouts/beneficiaries/{beneficiaryId}" ); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`:** [Beneficiary object](#beneficiary-object).
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | Beneficiary is unknown, inactive, or belongs to another account | | `429` | `RATE_LIMITED` | The account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Delete beneficiary Soft-deletes the beneficiary and its [deposit trigger address](#create-a-deposit-trigger-address), if present. Sign `@method` and `@path` only; no `Content-Digest` is required. ```bash curl -X DELETE "https://api.sandbox.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; const response = await fetch( `https://api.sandbox.blox.my/v1/payouts/beneficiaries/${beneficiaryId}`, { method: "DELETE", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Signature-Input": signatureInput, // (@method @path) only Signature: signature, }, }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python beneficiary_id = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55" response = requests.delete( f"https://api.sandbox.blox.my/v1/payouts/beneficiaries/{beneficiary_id}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Signature-Input": signature_input, # (@method @path) only "Signature": signature, }, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go beneficiaryID := "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55" req, _ := http.NewRequest( "DELETE", "https://api.sandbox.blox.my/v1/payouts/beneficiaries/"+beneficiaryID, nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) // (@method @path) only req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; var request = HttpRequest.newBuilder(URI.create( "https://api.sandbox.blox.my/v1/payouts/beneficiaries/" + beneficiaryId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) // (@method @path) only .header("Signature", signature) .DELETE(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var beneficiaryId = "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55"; using var request = new HttpRequestMessage( HttpMethod.Delete, "https://api.sandbox.blox.my/v1/payouts/beneficiaries/" + beneficiaryId ); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.TryAddWithoutValidation("Signature-Input", signatureInput); request.Headers.TryAddWithoutValidation("Signature", signature); var response = await new HttpClient().SendAsync(request); ``` **Response — `200`** ```json { "success": true } ```
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key or signature is missing or invalid | | `403` | `FEATURE_DISABLED` | Payout is not enabled for the account | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | Beneficiary is unknown, inactive, or belongs to another account | | `429` | `RATE_LIMITED` | The account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create payout Creates and immediately submits a payout. The accepted payout is debited from `available` and returned as `INITIATED`. This endpoint is limited to **300 requests per minute** per account. **Header** | Header | Required | Description | |--------|----------|-------------| | `Idempotency-Key` | Yes | UUID v4 persisted per logical payout and reused on retry | **Body** | Field | Type | Required | Constraints | |-------|------|----------|-------------| | `amount` | integer | Yes | `100`–`10000000` sen | | `feeMode` | string | Yes | `DEDUCT_FROM_AMOUNT` or `CHARGE_TO_PREFUND` | | `beneficiaryId` | string (UUID) | Conditional | Exactly one of `beneficiaryId` or `beneficiary` | | `beneficiary` | object | Conditional | Same fields as [Create beneficiary](#create-beneficiary) | | `reference` | string | No | 1–20 trimmed characters; generated as `PO` + 8 characters when omitted | `DEDUCT_FROM_AMOUNT` sends `amount - fee`; `CHARGE_TO_PREFUND` sends the full `amount`. In both cases, the prefund debit is `netAmount + fee`. Read both values from the response. `amount` is the gross value before fees. With `DEDUCT_FROM_AMOUNT`, `netAmount` must remain at least RM1.00; under the standard RM1.50 fee floor, the smallest valid gross amount is RM2.50. Inline beneficiaries are stored on first use and deduplicated by bank, account number, and case-insensitive name. Registered beneficiaries are optional. ```bash curl -X POST "https://api.sandbox.blox.my/v1/payouts" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen | tr 'A-Z' 'a-z')" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ --data '{"amount":100000,"feeMode":"DEDUCT_FROM_AMOUNT","beneficiaryId":"7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55","reference":"INV-4471"}' ``` ```javascript const path = "/v1/payouts"; const body = { amount: 100000, feeMode: "DEDUCT_FROM_AMOUNT", beneficiaryId: "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55", reference: "INV-4471", }; const idempotencyKey = crypto.randomUUID(); // persist before sending const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python path = "/v1/payouts" body = { "amount": 100000, "feeMode": "DEDUCT_FROM_AMOUNT", "beneficiaryId": "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55", "reference": "INV-4471", } idempotency_key = str(uuid.uuid4()) # persist before sending headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() print(response.json()) ``` ```go raw := []byte(`{"amount":100000,"feeMode":"DEDUCT_FROM_AMOUNT","beneficiaryId":"7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55","reference":"INV-4471"}`) var body any json.Unmarshal(raw, &body) path := "/v1/payouts" headers, _ := signBlox("POST", path, body) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(raw)) req.Header = headers req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", uuid.NewString()) // persist before sending response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts"; String body = """ {"amount":100000,"feeMode":"DEDUCT_FROM_AMOUNT","beneficiaryId":"7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55","reference":"INV-4471"} """; var headers = signBlox("POST", path, body); var builder = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Idempotency-Key", UUID.randomUUID().toString()) .POST(HttpRequest.BodyPublishers.ofString(body)); headers.forEach(builder::header); var response = HttpClient.newHttpClient().send( builder.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp const string path = "/v1/payouts"; var body = """ {"amount":100000,"feeMode":"DEDUCT_FROM_AMOUNT","beneficiaryId":"7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55","reference":"INV-4471"} """; var headers = SignBlox("POST", path, body, privateKey); using var request = new HttpRequestMessage( HttpMethod.Post, "https://api.sandbox.blox.my" + path ); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); foreach (var header in headers) request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await new HttpClient().SendAsync(request); ``` **Response — `200`** ```json { "payoutId": "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10", "status": "INITIATED", "type": "STANDARD", "amount": "100000", "fee": "1000", "netAmount": "99000", "beneficiaryId": "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55", "bankAccountId": null, "idempotencyKey": "6f9619ff-8b86-d011-b42d-00cf4fc964ff", "reference": "INV-4471", "statusReason": null, "createdAt": "2026-07-16T09:30:00.000Z", "submittedAt": null } ``` The response uses the shared [Payout object](#payout-object). A `202` response means the original request for that key is still processing; retry with the **same** key. **Idempotency rules** * Persist one UUID v4 before sending a logical payout. * Retry a timeout, `429`, `500`, or `503` with the same key. * Retry a rejected `4xx` with a new key after correcting the request. * Reusing a key with a different body returns `422 VALIDATION_FAILED`. See [Payout Errors](/api/payout-api/errors) for the complete code and retry table.
Error responses | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | The body is invalid, including both or neither beneficiary fields | | `400` | `INVALID_REQUEST` | The idempotency key is invalid, bank verification fails, or the net amount is below RM1 | | `400` | `INSUFFICIENT_BALANCE` | The prefund cannot cover `netAmount + fee` | | `400` | `UNKNOWN_BENEFICIARY` | The beneficiary is inactive, cross-account, or the bank code is not in your `/v1/payouts/active-banks` list | | `400` | `LIMIT_EXCEEDED` | A configured daily or monthly payout limit is reached | | `401` | `UNAUTHORIZED` | API key or signature is missing or invalid | | `403` | `LIMIT_EXCEEDED` | Storing a new inline beneficiary would exceed the active-beneficiary cap | | `403` | `FEATURE_DISABLED` | Payout is disabled or suspended by an uncovered prefund reversal | | `403` | `ACCOUNT_FROZEN` / `ACCOUNT_PENDING_VERIFICATION` / `ACCOUNT_TERMINATED` | The account state blocks payouts | | `422` | `VALIDATION_FAILED` | The idempotency key was already used with a different body | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `503` | `SERVICE_UNAVAILABLE` | Payout creation is paused; nothing was created or debited | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

400 VALIDATION\_FAILED For a request with `amount: 99`: ```json { "code": "VALIDATION_FAILED", "message": "amount: Amount can't be less than 1 MYR", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 INVALID\_REQUEST ```json { "code": "INVALID_REQUEST", "message": "Idempotency-Key must be a valid UUID v4", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 INSUFFICIENT\_BALANCE ```json { "code": "INSUFFICIENT_BALANCE", "message": "Insufficient prefund balance", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 UNKNOWN\_BENEFICIARY ```json { "code": "UNKNOWN_BENEFICIARY", "message": "Beneficiary not found", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
400 LIMIT\_EXCEEDED ```json { "code": "LIMIT_EXCEEDED", "message": "Daily payout limit exceeded", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 LIMIT\_EXCEEDED ```json { "code": "LIMIT_EXCEEDED", "message": "Active beneficiary limit exceeded", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 FEATURE\_DISABLED ```json { "code": "FEATURE_DISABLED", "message": "Payout is not enabled for this account", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_FROZEN ```json { "code": "ACCOUNT_FROZEN", "message": "Account is frozen", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_PENDING\_VERIFICATION ```json { "code": "ACCOUNT_PENDING_VERIFICATION", "message": "Account is pending verification", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
403 ACCOUNT\_TERMINATED ```json { "code": "ACCOUNT_TERMINATED", "message": "Account is terminated", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
422 VALIDATION\_FAILED ```json { "code": "VALIDATION_FAILED", "message": "Idempotency key was already used with a different request body", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
429 RATE\_LIMITED ```json { "code": "RATE_LIMITED", "message": "Too many payout requests. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
503 SERVICE\_UNAVAILABLE ```json { "code": "SERVICE_UNAVAILABLE", "message": "Payout service is temporarily unavailable. Please try again later.", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Get payout Returns a payout by `payoutId`. Use webhooks for status changes; use this endpoint for reconciliation or missed webhook recovery. The endpoint is limited to **600 requests per minute** per account. | Path parameter | Type | Description | |----------------|------|-------------| | `id` | string (UUID) | Payout owned by your account | ```bash curl "https://api.blox.my/v1/payouts/$PAYOUT_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const payoutId = "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10"; const response = await fetch(`https://api.blox.my/v1/payouts/${payoutId}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python payout_id = "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10" response = requests.get( f"https://api.blox.my/v1/payouts/{payout_id}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go payoutID := "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10" req, _ := http.NewRequest("GET", "https://api.blox.my/v1/payouts/"+payoutID, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` ```java String payoutId = "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10"; var request = HttpRequest.newBuilder() .uri(URI.create("https://api.blox.my/v1/payouts/" + payoutId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET() .build(); var response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString() ); System.out.println(response.body()); ``` ```csharp var payoutId = "b0e6c2f4-2a1d-4a9e-9f3c-2c9f5a1b7e10"; using var client = new HttpClient(); client.DefaultRequestHeaders.Add( "blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY") ); var response = await client.GetAsync($"https://api.blox.my/v1/payouts/{payoutId}"); response.EnsureSuccessStatusCode(); Console.WriteLine(await response.Content.ReadAsStringAsync()); ``` **Response — `200`:** [Payout object](#payout-object). Unknown and cross-account IDs return `404 NOT_FOUND`.
Error responses | Status | `code` | When | |--------|--------|------| | `401` | `UNAUTHORIZED` | API key is missing, unknown, or inactive | | `403` | `FEATURE_DISABLED` | Payout is disabled, including suspension by an uncovered prefund reversal | | `403` | `ACCOUNT_TERMINATED` | The account is terminated | | `404` | `NOT_FOUND` | Payout is unknown or belongs to another account | | `429` | `RATE_LIMITED` | The route or account rate limit is exceeded | | `5xx` | `INTERNAL_ERROR` | Unexpected BLOX failure; report `requestId` |

Error examples

401 UNAUTHORIZED ```json { "code": "UNAUTHORIZED", "message": "API key is required", "requestId": "9f21ab04-6c3e-4d90-b1a7-8e5f2c0d4416" } ```
## Create a deposit trigger address Gives a beneficiary an on-chain address. Tokens sent to it are paid to that beneficiary automatically, with no `POST /v1/payouts` from you and without touching your prefund balance. See [Pay a beneficiary from a deposit](/api/payout-api/overview#step-7-optional-pay-a-beneficiary-from-a-deposit). The beneficiary must be active. Calling this again returns the same address — a beneficiary has one, for its lifetime — so no `Idempotency-Key` is needed. Limited to 20 calls per minute. Deleting the beneficiary deactivates its address. ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID/address" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/payouts/beneficiaries/${beneficiaryId}/address`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, ...signBlox("POST", path), }, }); if (!response.ok) throw new Error(await response.text()); const { address } = await response.json(); ``` ```python path = f"/v1/payouts/beneficiaries/{beneficiary_id}/address" headers = sign_blox("POST", path) headers["blox-api-key"] = os.environ["BLOX_API_KEY"] response = requests.post( f"https://api.sandbox.blox.my{path}", headers=headers, timeout=10 ) response.raise_for_status() address = response.json()["address"] ``` ```go path := "/v1/payouts/beneficiaries/" + beneficiaryID + "/address" req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/beneficiaries/" + beneficiaryId + "/address"; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.noBody()); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/payouts/beneficiaries/{beneficiaryId}/address"; var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) ```json { "address": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "network": "EVM", "destination": { "type": "BENEFICIARY", "id": "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55" } } ``` | Field | Type | Description | |-------|------|-------------| | `address` | string | Send supported tokens here | | `network` | string | `EVM` | | `destination.type` | string | `BENEFICIARY` | | `destination.id` | string (UUID) | The beneficiary who gets paid | The address ignores every deposit until you allow at least one sender. The same value appears as `autoWithdrawalAddress` on the [Beneficiary object](#beneficiary-object).
Expected errors | Status | `code` | When | |--------|--------|------| | `403` | `FEATURE_DISABLED` | Paying a beneficiary from a deposit is not enabled on your account | | `404` | `NOT_FOUND` | Unknown beneficiary, one belonging to another account, or one that is not active | | `429` | `RATE_LIMITED` | Over 20 calls in the last minute |
## List allowed senders Returns the senders allowed to trigger this beneficiary's address, oldest first. ```bash curl --fail-with-body \ "https://api.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID/address/whitelist" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/payouts/beneficiaries/${beneficiaryId}/address/whitelist`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```python response = requests.get( f"https://api.blox.my/v1/payouts/beneficiaries/{beneficiary_id}/address/whitelist", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() print(response.json()) ``` ```go url := "https://api.blox.my/v1/payouts/beneficiaries/" + beneficiaryID + "/address/whitelist" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) ``` ```java var request = HttpRequest.newBuilder(URI.create( "https://api.blox.my/v1/payouts/beneficiaries/" + beneficiaryId + "/address/whitelist")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.blox.my/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json [ { "id": "3f8c1d02-5b47-4a6e-91cd-77e2b0a4f915", "address": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "label": "Treasury wallet", "active": true, "createdAt": "2026-08-07T09:31:05.000Z" } ] ``` | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Pass this to [disable](#disable-or-re-enable-a-sender) or [remove](#remove-an-allowed-sender) the sender | | `address` | string | Stored lowercase | | `label` | string | null | Your own note | | `active` | boolean | Only active senders are accepted; see [disable](#disable-or-re-enable-a-sender) | | `createdAt` | string (ISO 8601) | Creation time | Disabled senders are listed too. They count toward the 50-sender limit until you remove them. `404` `NOT_FOUND` if the beneficiary has no trigger address yet. ## Allow a sender Allows one address to trigger payouts to this beneficiary. Up to 50 per address. | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `address` | string | Yes | `0x` and 40 hex characters | The address you send from. Casing is ignored | | `label` | string | No | 1–120 characters | Your own note, returned on reads | ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID/address/whitelist" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"address":"0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02","label":"Treasury wallet"}' ``` ```javascript const path = `/v1/payouts/beneficiaries/${beneficiaryId}/address/whitelist`; const body = { address: senderAddress, label: "Treasury wallet" }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/payouts/beneficiaries/{beneficiary_id}/address/whitelist" body = {"address": sender_address, "label": "Treasury wallet"} headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() ``` ```go path := "/v1/payouts/beneficiaries/" + beneficiaryID + "/address/whitelist" payload, _ := json.Marshal(map[string]string{ "address": senderAddress, "label": "Treasury wallet", }) req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, bytes.NewReader(payload)) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/beneficiaries/" + beneficiaryId + "/address/whitelist"; String payload = """ {"address":"%s","label":"Treasury wallet"}""".formatted(senderAddress); var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.ofString(payload)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist"; var payload = JsonSerializer.Serialize(new { address = senderAddress, label = "Treasury wallet" }); var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) Returns the created sender in the same shape as [List allowed senders](#list-allowed-senders).
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `address` is not a valid address, or `label` is longer than 120 characters | | `400` | `CONFLICT` | That address is already allowed here | | `403` | `LIMIT_EXCEEDED` | The address already has 50 allowed senders | | `404` | `NOT_FOUND` | The beneficiary has no trigger address, or is not yours |
## Disable or re-enable a sender Turns a sender off without losing it. A disabled sender stops triggering transfers immediately, keeps its label, and stays in the list ready to be turned back on. Use this to pause a wallet you expect to send from again; use [remove](#remove-an-allowed-sender) when you are done with it, which is also what frees its place against the 50-sender limit. ```bash curl -X PATCH --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID/address/whitelist/$SENDER_ID" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Content-Digest: $CONTENT_DIGEST" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"active":false}' ``` ```javascript const path = `/v1/payouts/beneficiaries/${beneficiaryId}/address/whitelist/${senderId}`; const body = JSON.stringify({ active: false }); const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "PATCH", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", "Content-Digest": contentDigest, "Signature-Input": signatureInput, Signature: signature, }, body, }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/payouts/beneficiaries/{beneficiary_id}/address/whitelist/{sender_id}" response = requests.patch( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", "Content-Digest": content_digest, "Signature-Input": signature_input, "Signature": signature, }, data=body, timeout=10, ) response.raise_for_status() ``` ```go path := "/v1/payouts/beneficiaries/" + beneficiaryID + "/address/whitelist/" + senderID req, _ := http.NewRequest("PATCH", "https://api.sandbox.blox.my"+path, bytes.NewReader(body)) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Digest", contentDigest) req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/beneficiaries/" + beneficiaryId + "/address/whitelist/" + senderId; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Content-Digest", contentDigest) .header("Signature-Input", signatureInput) .header("Signature", signature) .method("PATCH", HttpRequest.BodyPublishers.ofString(body)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist/{senderId}"; var request = new HttpRequestMessage(HttpMethod.Patch, $"https://api.sandbox.blox.my{path}") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Content-Digest", contentDigest); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Request body | Field | Type | Description | |-------|------|-------------| | `active` | boolean | `false` to disable, `true` to re-enable | ### Success response (`200`) Returns the sender in the same shape as [List allowed senders](#list-allowed-senders).
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `active` is missing or is not a boolean | | `403` | `ACCOUNT_FROZEN` | The account is frozen. [Removing](#remove-an-allowed-sender) a sender still works | | `403` | `ACCOUNT_PENDING_VERIFICATION` | The account is pending verification | | `404` | `NOT_FOUND` | The sender id is unknown or belongs to a different address |
## Remove an allowed sender Stops accepting deposits from that sender and frees its place against the 50-sender limit. Deposits already on their way are unaffected. Sign `@method` and `@path` only; no `Content-Digest` is required. ```bash curl -X DELETE --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/beneficiaries/$BENEFICIARY_ID/address/whitelist/$SENDER_ID" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/payouts/beneficiaries/${beneficiaryId}/address/whitelist/${senderId}`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "DELETE", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Signature-Input": signatureInput, // (@method @path) only Signature: signature, }, }); if (!response.ok) throw new Error(await response.text()); ``` ```python path = f"/v1/payouts/beneficiaries/{beneficiary_id}/address/whitelist/{sender_id}" response = requests.delete( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Signature-Input": signature_input, # (@method @path) only "Signature": signature, }, timeout=10, ) response.raise_for_status() ``` ```go path := "/v1/payouts/beneficiaries/" + beneficiaryID + "/address/whitelist/" + senderID req, _ := http.NewRequest("DELETE", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) // (@method @path) only req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/beneficiaries/" + beneficiaryId + "/address/whitelist/" + senderId; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) // (@method @path) only .header("Signature", signature) .DELETE(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/payouts/beneficiaries/{beneficiaryId}/address/whitelist/{senderId}"; var request = new HttpRequestMessage(HttpMethod.Delete, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json { "success": true } ``` `404` `NOT_FOUND` if the sender id is unknown or belongs to a different address. ## Get a deposit Returns a deposit to a beneficiary's trigger address and the transfer it produced. This is the same payload the [deposit webhook](/api/payout-api/webhooks) delivers. A deposit is readable on the channel that produced it. Deposits paid into your own bank account are read with [`GET /v1/wallet/deposits/{id}`](/api/wallet-api/endpoints#get-a-deposit). ```bash curl --fail-with-body "https://api.blox.my/v1/payouts/deposits/$DEPOSIT_ID" \ -H "blox-api-key: $BLOX_API_KEY" ``` ```javascript const response = await fetch( `https://api.blox.my/v1/payouts/deposits/${depositId}`, { headers: { "blox-api-key": process.env.BLOX_API_KEY } }, ); if (!response.ok) throw new Error(await response.text()); const deposit = await response.json(); ``` ```python response = requests.get( f"https://api.blox.my/v1/payouts/deposits/{deposit_id}", headers={"blox-api-key": os.environ["BLOX_API_KEY"]}, timeout=10, ) response.raise_for_status() deposit = response.json() ``` ```go req, _ := http.NewRequest( "GET", "https://api.blox.my/v1/payouts/deposits/"+depositID, nil, ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) response, err := http.DefaultClient.Do(req) ``` ```java var request = HttpRequest.newBuilder(URI.create( "https://api.blox.my/v1/payouts/deposits/" + depositId)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .GET(); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.blox.my/v1/payouts/deposits/{depositId}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); var response = await httpClient.SendAsync(request); ``` ### Success response (`200`) ```json { "id": "5e9d0273-8a41-4c62-b0f7-1d3e8c95a460", "txHash": "0x5d5355...103ee3", "logIndex": 12, "chainId": 1, "tokenId": "a71c4e08-2f96-4b3d-85ae-6c0f7d21b943", "amount": "100000", "from": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "status": "COMPLETED", "confirmations": 24, "triggerAddress": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "destination": { "type": "BENEFICIARY", "id": "7c1a3e88-9d2b-4f61-8a44-1f0b6d3c9a55", "name": "ADA LOVELACE" }, "result": { "status": "COMPLETED", "statusReason": null, "withdrawal": { "id": "c4a80f13-6d29-4e75-83b1-9f0c2a7e5d68", "amount": "100000", "status": "COMPLETED", "refId": "FW-20260807-0001", "reference": "Invoice 4471", "createdAt": "2026-08-07T09:31:05.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } }, "createdAt": "2026-08-07T09:30:44.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | The deposit id | | `txHash` | string | Transaction that carried the deposit | | `logIndex` | integer | Position of this transfer within the transaction | | `chainId` | integer | Chain the deposit arrived on | | `tokenId` | string (UUID) | Token deposited | | `amount` | string | Deposit amount in the token's smallest unit | | `from` | string | The address the tokens were sent from | | `status` | string | `PENDING` while confirming, `COMPLETED` once credited, `FAILED` if it did not credit | | `confirmations` | integer | Confirmations seen so far | | `triggerAddress` | string | The beneficiary address that received it | | `destination` | object | The beneficiary being paid | | `result` | object | null | The transfer outcome | | `result.status` | string | `PENDING`, `PROCESSING`, `COMPLETED`, or `FAILED` | | `result.statusReason` | string | null | Why it failed. See [Deposit status reason values](#deposit-status-reason-values) | | `result.withdrawal` | object | null | The bank transfer, once one exists | `amount` and `result.withdrawal.amount` use different units — the token's smallest unit and sen. Read each from its own field. A transfer produced this way does not appear in [`GET /v1/payouts`](#get-payout) and does not use your prefund balance. `404` `NOT_FOUND` for an unknown deposit id, one belonging to another account, or one paid into your own bank account. ### Deposit status reason values `result.statusReason` is `null` unless `result.status` is `FAILED`. These are distinct from the [payout status reason values](#status-reason-values), which describe a payout you created. | Value | Meaning | |-------|---------| | `sender_not_allowed` | The deposit came from an address you have not allowed | | `inactive_destination` | The beneficiary was deleted or deactivated | | `missing_destination_bank_details` | The beneficiary has no bank on file | | `feature_disabled` | Paying a beneficiary from a deposit is not enabled on your account | | `account_frozen`, `account_suspended`, `account_terminated` | Your account status blocked it | | `withdrawal_amount_out_of_range` | Below the minimum or above the maximum for your account | | `daily_fiat_withdrawal_limit_exceeded` | Daily limit reached | | `monthly_fiat_withdrawal_limit_exceeded` | Monthly limit reached | | `fiat_withdrawal_rejected`, `fiat_withdrawal_failed`, `fiat_withdrawal_cancelled` | The bank transfer did not complete | | `provider_error` | Anything else the rail reported. Contact BLOX with the deposit `id` | Deposits from before this vocabulary settled may still read `inactive_beneficiary` or `missing_beneficiary_bank_details` — the older names for `inactive_destination` and `missing_destination_bank_details`. The tokens stay credited to your wallet in every case. Where the cause is yours to fix, fix it and [retry the deposit](#retry-a-deposit). ## Check for a missed deposit Asks BLOX to look at a transaction it never picked up. Use it when you sent tokens to a beneficiary's address and nothing arrived. This is the only endpoint that takes a transaction hash. When the transaction turns out to be known already, the response carries the deposit ids — use those with [Get a deposit](#get-a-deposit) and [Retry a deposit](#retry-a-deposit). Limited to 30 calls per minute. | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `txHash` | string | Yes | `0x` and 64 hex characters | Transaction that carried the deposit | | `chainId` | integer | Yes | An active BLOX network | Chain the transaction is on | ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/deposits/check-missed" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Content-Type: application/json" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" \ -d '{"txHash":"0x5d5355...103ee3","chainId":1}' ``` ```javascript const path = "/v1/payouts/deposits/check-missed"; const body = { txHash, chainId: 1 }; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Content-Type": "application/json", ...signBlox("POST", path, body), }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(await response.text()); const { status, depositIds } = await response.json(); ``` ```python path = "/v1/payouts/deposits/check-missed" body = {"txHash": tx_hash, "chainId": 1} headers = sign_blox("POST", path, body) headers.update({ "blox-api-key": os.environ["BLOX_API_KEY"], "Content-Type": "application/json", }) response = requests.post( f"https://api.sandbox.blox.my{path}", json=body, headers=headers, timeout=10 ) response.raise_for_status() result = response.json() ``` ```go payload, _ := json.Marshal(map[string]any{"txHash": txHash, "chainId": 1}) req, _ := http.NewRequest( "POST", "https://api.sandbox.blox.my/v1/payouts/deposits/check-missed", bytes.NewReader(payload), ) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Signature-Input", signatureInput) req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String payload = """ {"txHash":"%s","chainId":1}""".formatted(txHash); var request = HttpRequest.newBuilder(URI.create( "https://api.sandbox.blox.my/v1/payouts/deposits/check-missed")) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Content-Type", "application/json") .header("Signature-Input", signatureInput) .header("Signature", signature) .POST(HttpRequest.BodyPublishers.ofString(payload)); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var payload = JsonSerializer.Serialize(new { txHash, chainId = 1 }); var request = new HttpRequestMessage(HttpMethod.Post, "https://api.sandbox.blox.my/v1/payouts/deposits/check-missed") { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) ```json { "status": "ALREADY_DETECTED", "depositIds": ["5e9d0273-8a41-4c62-b0f7-1d3e8c95a460"] } ``` | Field | Type | Description | |-------|------|-------------| | `status` | string | `QUEUED` if the transaction is now being picked up, `ALREADY_DETECTED` if BLOX had it | | `depositIds` | string\[] | The deposits in that transaction. Populated on `ALREADY_DETECTED`, empty on `QUEUED` | Check `status` before reading `depositIds`: an empty array on `QUEUED` means the deposits do not exist yet, not that none were found. One transaction can pay several beneficiaries. On `QUEUED`, wait for the webhook rather than calling again.
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `VALIDATION_FAILED` | `txHash` is not a valid hash, or `chainId` is not a positive integer | | `403` | `FEATURE_DISABLED` | Paying a beneficiary from a deposit is not enabled on your account | | `404` | `NOT_FOUND` | No active BLOX network matches `chainId` | | `429` | `RATE_LIMITED` | Over 30 calls in the last minute |
## Retry a deposit Re-evaluates a deposit that was credited but produced no transfer. Use it after fixing the cause — allowing the sender, reactivating the beneficiary, or waiting for a limit to roll over. Safe to call more than once. A deposit that already paid out returns its existing transfer, and no second transfer is created. Limited to 60 calls per minute. No request body. Sign `@method` and `@path` only. ```bash curl -X POST --fail-with-body \ "https://api.sandbox.blox.my/v1/payouts/deposits/$DEPOSIT_ID/redrive" \ -H "blox-api-key: $BLOX_API_KEY" \ -H "Signature-Input: $SIGNATURE_INPUT" \ -H "Signature: $SIGNATURE" ``` ```javascript const path = `/v1/payouts/deposits/${depositId}/redrive`; const response = await fetch(`https://api.sandbox.blox.my${path}`, { method: "POST", headers: { "blox-api-key": process.env.BLOX_API_KEY, "Signature-Input": signatureInput, // (@method @path) only Signature: signature, }, }); if (!response.ok) throw new Error(await response.text()); const deposit = await response.json(); ``` ```python path = f"/v1/payouts/deposits/{deposit_id}/redrive" response = requests.post( f"https://api.sandbox.blox.my{path}", headers={ "blox-api-key": os.environ["BLOX_API_KEY"], "Signature-Input": signature_input, # (@method @path) only "Signature": signature, }, timeout=10, ) response.raise_for_status() deposit = response.json() ``` ```go path := "/v1/payouts/deposits/" + depositID + "/redrive" req, _ := http.NewRequest("POST", "https://api.sandbox.blox.my"+path, nil) req.Header.Set("blox-api-key", os.Getenv("BLOX_API_KEY")) req.Header.Set("Signature-Input", signatureInput) // (@method @path) only req.Header.Set("Signature", signature) response, err := http.DefaultClient.Do(req) ``` ```java String path = "/v1/payouts/deposits/" + depositId + "/redrive"; var request = HttpRequest.newBuilder(URI.create("https://api.sandbox.blox.my" + path)) .header("blox-api-key", System.getenv("BLOX_API_KEY")) .header("Signature-Input", signatureInput) // (@method @path) only .header("Signature", signature) .POST(HttpRequest.BodyPublishers.noBody()); var response = HttpClient.newHttpClient().send( request.build(), HttpResponse.BodyHandlers.ofString() ); ``` ```csharp var path = $"/v1/payouts/deposits/{depositId}/redrive"; var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.blox.my{path}"); request.Headers.Add("blox-api-key", Environment.GetEnvironmentVariable("BLOX_API_KEY")); request.Headers.Add("Signature-Input", signatureInput); request.Headers.Add("Signature", signature); var response = await httpClient.SendAsync(request); ``` ### Success response (`201`) The deposit, in the same shape as [Get a deposit](#get-a-deposit), read back after the retry.
Expected errors | Status | `code` | When | |--------|--------|------| | `400` | `INVALID_REQUEST` | The deposit has not finished confirming, so there is nothing to retry | | `403` | `FEATURE_DISABLED` | Paying a beneficiary from a deposit is not enabled on your account | | `404` | `NOT_FOUND` | Unknown deposit, one belonging to another account, or one that did not arrive on a beneficiary's trigger address | | `429` | `RATE_LIMITED` | Over 60 calls in the last minute |
## Shared response schemas ### Beneficiary object | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Send as `beneficiaryId` when creating a payout | | `name` | string | Uppercased beneficiary name | | `accountNumber` | string | Bank account number | | `bank.code` | string | Short bank code | | `bank.name` | string | Bank display name | | `autoWithdrawalAddress` | string | null | The beneficiary's [deposit trigger address](#create-a-deposit-trigger-address), or `null` if it has none | | `createdAt` | string (ISO 8601) | Creation time | | `updatedAt` | string (ISO 8601) | Last update time | ### Payout object | Field | Type | Description | |-------|------|-------------| | `payoutId` | string (UUID) | Unique payout identifier | | `status` | string | `INITIATED`, `SETTLED`, `REVERSED`, or `RETURNED` | | `type` | string | `STANDARD` for your payout; `PREFUND_RETURN` when BLOX returns part of your prefund balance | | `amount` | string | Requested amount in sen | | `fee` | string | Fee in sen | | `netAmount` | string | Amount sent to the beneficiary | | `beneficiaryId` | string (UUID) | null | Beneficiary paid; `null` on a `PREFUND_RETURN` | | `bankAccountId` | string (UUID) | null | Your own bank account, on a `PREFUND_RETURN`; `null` otherwise | | `idempotencyKey` | string | null | Original create key; `null` on a `PREFUND_RETURN` | | `reference` | string | Bank-facing transfer reference | | `statusReason` | string | null | Stable reason key from the table below | | `createdAt` | string (ISO 8601) | Creation time | | `submittedAt` | string (ISO 8601) | null | Time handed to the bank rails | Exactly one of `beneficiaryId` and `bankAccountId` is ever set. Everything you create is `STANDARD`, with a beneficiary. The create response always has `submittedAt: null`; read the payout later if you need the submission time. `INITIATED` covers both queued and bank-pending payouts, and `submittedAt` distinguishes them. ### Status reason values | Value | Meaning | |-------|---------| | `pending` | Submitted; no terminal bank response yet | | `rejected_by_bank` | Bank rejected the transfer | | `returned_by_bank` | Settled transfer was returned | | `invalid_beneficiary_bank` | Bank cannot be routed to | | `under_review` | Held for manual resolution; contact BLOX | | `provider_unavailable` | Temporary provider fault | | `reversed_by_support` | BLOX manually reversed a stranded payout | | `provider_error` | Anything else the rail reported. Contact BLOX with the `payoutId` | | `provider_error` | Other provider failure; quote the `payoutId` to BLOX | Branch on these stable keys, never on human-readable messages. The REST record and [Webhook Events](/api/payout-api/webhooks) use the same values. # Payout Webhook Events Account-level webhook events for prefund payouts. Register a `PAYOUT`-type endpoint from the dashboard (**Devtools → Webhooks**). Registration, signature verification, retries, and URL rules: [Webhooks](/api/webhooks). ## Events | Event | Fires when | |-------|------------| | `payout.created` | Payout created (`INITIATED`) | | `payout.updated` | Status changes (`SETTLED`, `REVERSED`, `RETURNED`) | | `payout.prefund_completed` | A prefund top-up settled and is spendable | | `payout.prefund_reversed` | The bank reversed a settled top-up | | `payout.prefund_recredited` | A reversed top-up was re-confirmed and credited back | | `payout.deposit.updated` | A deposit to a beneficiary's trigger address is credited, or is retried | | `payout.withdrawal.updated` | A transfer produced by such a deposit changes status | The last two belong to [paying a beneficiary from a deposit](/api/payout-api/overview#step-7-optional-pay-a-beneficiary-from-a-deposit). They are the only notification for it, because you never create a request to start one. Transfers made this way do not appear in `payout.created` or `payout.updated`. ## Envelope ```json { "eventId": "payout.updated:b0e6c2f4-...:SETTLED", "event": "payout.updated", "webhookType": "PAYOUT", "timestamp": "2026-07-16T09:31:05.000Z", "data": {} } ``` | Field | Description | |-------|-------------| | `eventId` | Unique — use for idempotency | | `event` | Event name | | `webhookType` | `PAYOUT` for these events | | `timestamp` | ISO 8601 | | `data` | Event-specific payload | ## Event data ### `payout.created` / `payout.updated` ```json { "payoutId": "b0e6c2f4-...", "status": "SETTLED", "type": "STANDARD", "amount": "100000", "fee": "1000", "netAmount": "99000", "beneficiaryId": "7c1a3e88-...", "bankAccountId": null, "idempotencyKey": "6f9619ff-8b86-d011-b42d-00cf4fc964ff", "statusReason": null } ``` Fields match the REST [Payout object](/api/payout-api/endpoints#payout-object), but `data` omits `reference`, `createdAt`, and `submittedAt`. Call [`GET /v1/payouts/{id}`](/api/payout-api/endpoints#get-payout) only when you need the full record or must reconcile a missed event. ### `payout.prefund_completed` / `payout.prefund_reversed` / `payout.prefund_recredited` ```json { "depositId": "3f8c1d20-...", "amount": "5000000" } ``` | Field | Description | |-------|-------------| | `depositId` | The top-up this event is about | | `amount` | Credited (or reversed) amount, a string in sen | `amount` is the movement, not the resulting balance. Read [`GET /v1/payout/prefund/balance`](/api/payout-api/endpoints#get-prefund-balance) after the event when you need the current balance. ### `payout.deposit.updated` ```json { "id": "5e9d0273-...", "txHash": "0x5d5355...103ee3", "logIndex": 12, "chainId": 1, "tokenId": "a71c4e08-...", "amount": "100000", "from": "0x9a3f7c21b8e04d6f5a19c2e70b48d3f61a5e9d02", "status": "COMPLETED", "confirmations": 24, "triggerAddress": "0x7c1a3e889d2b4f618a441f0b6d3c9a5588ab01cd", "destination": { "type": "BENEFICIARY", "id": "7c1a3e88-...", "name": "ADA LOVELACE" }, "result": { "status": "FAILED", "statusReason": "sender_not_allowed", "withdrawal": null }, "createdAt": "2026-08-07T09:30:44.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` Fields match the REST record — see [`GET /v1/payouts/deposits/{id}`](/api/payout-api/endpoints#get-a-deposit) for the full table and the [deposit status reason values](/api/payout-api/endpoints#deposit-status-reason-values). The event arrives once the deposit is credited and the transfer it triggered has been decided, so one event tells you the tokens arrived and what happened next. A deposit refused for a reason you can fix says so here, and can be retried. ### `payout.withdrawal.updated` ```json { "withdrawalId": "c4a80f13-...", "status": "COMPLETED", "statusReason": null, "amount": "100000", "refId": "FW-20260807-0001", "reference": "Invoice 4471", "destination": { "type": "BENEFICIARY", "id": "7c1a3e88-...", "name": "ADA LOVELACE" }, "depositId": "5e9d0273-...", "createdAt": "2026-08-07T09:31:05.000Z", "updatedAt": "2026-08-07T09:34:22.000Z" } ``` | Field | Description | |-------|-------------| | `withdrawalId` | The transfer this event is about | | `status` | `PENDING`, `PROCESSING`, `COMPLETED`, `REJECTED`, `FAILED`, or `CANCELLED` | | `statusReason` | Set only on `REJECTED`, `FAILED`, and `CANCELLED` | | `amount` | A string in sen | | `refId` | Bank reference | | `reference` | Your own reference, where one was set | | `destination` | The beneficiary paid | | `depositId` | The deposit that triggered it — join the two events on this | These transfers are separate from prefund payouts: `withdrawalId` is not a `payoutId`, and reading it with [`GET /v1/payouts/{id}`](/api/payout-api/endpoints#get-payout) returns `404`. **`payout.prefund_reversed` is the one to alert on.** A reversal the balance can't cover leaves you owing BLOX and **suspends payouts on your account** — every subsequent create returns `403 FEATURE_DISABLED` until it is settled. Each fires at most once per top-up, so `eventId` is `:`. A single top-up can legitimately produce all three, in that order, if the bank confirms it, reverses it, then re-confirms it. # Payout Errors Every error on the merchant API has the same body: ```json { "code": "INSUFFICIENT_BALANCE", "message": "…", "requestId": "…" } ``` **`code` is the contract.** `message` is prose for humans, may be reworded at any time, and must never be parsed. `requestId` identifies the request in our logs — quote it when reporting a `5xx`. Adding a code is not a breaking change. Changing or removing one is. Shared auth and HTTP envelope: [Errors](/api/errors). ## Codes | Status | `code` | Meaning / action | |--------|--------|------------------| | 400 | `VALIDATION_FAILED` | The body failed schema validation — e.g. both or neither of `beneficiaryId` / `beneficiary`. `message` lists the offending fields. | | 400 | `INVALID_REQUEST` | Missing or malformed `Idempotency-Key`, an inline account that failed bank verification, or an `amount` that falls under the RM1 minimum once the fee is deducted. | | 400 | `INSUFFICIENT_BALANCE` | Fund your prefund (dashboard top-up or bank transfer) before retrying. | | 400 | `UNKNOWN_BENEFICIARY` | `beneficiaryId` doesn't match an active beneficiary on your account, or the inline `bankCode` isn't in your `/v1/payouts/active-banks` list. | | 400 | `LIMIT_EXCEEDED` | You hit a configured daily or monthly payout limit. | | 403 | `LIMIT_EXCEEDED` | You hit the cap on active beneficiaries (1,000 by default). Only a *new* destination trips this — repeat payouts to a destination you have already used reuse the existing record. Delete beneficiaries you no longer pay, or ask BLOX to raise the cap. | | 401 | `UNAUTHORIZED` | Missing/unknown API key, missing signature headers, or a signature that failed or fell outside ±300s. Re-sign with a fresh `created` over the current body digest. | | 403 | `FEATURE_DISABLED` | Payouts aren't enabled on the account. Contact BLOX. | | 403 | `ACCOUNT_FROZEN` / `ACCOUNT_SUSPENDED` / `ACCOUNT_PENDING_VERIFICATION` | Your account's status blocks money out. Reads stay open. Contact BLOX. | | 403 | `ACCOUNT_TERMINATED` | Every route is blocked, reads included. | | 404 | `NOT_FOUND` | Unknown `payoutId`, or it belongs to another account. | | 422 | `VALIDATION_FAILED` | The `Idempotency-Key` was already used with a different body. | | 429 | `RATE_LIMITED` | Over 300 creates per minute. Back off and retry. | | 503 | `SERVICE_UNAVAILABLE` | Payout creation is paused on our side. Nothing was created or debited — retry later, reusing the same `Idempotency-Key`. | | 5xx | `INTERNAL_ERROR` | Something broke on our side. `message` is deliberately generic; quote `requestId`. | ## Retry guidance | Response | Retry with | Why | |----------|-----------|-----| | `401` | Don't retry | Fix your signing or API key first | | `4xx` | A **new** `Idempotency-Key` | A rejection replays as the same rejection for 24 hours | | `429` | The **same** key | The request never ran | | `500`, `503`, timeout | The **same** key | The key is what stops one create from becoming two transfers | # Sandbox Testing Sandbox uses mocked bank rails. Outcomes are driven by the **last 4 digits of the beneficiary account number**. ## Base URL ``` https://api.sandbox.blox.my ``` ## Magic Account Suffixes | Account number ends with | Outcome | |--------------------------|---------| | *anything else* | Settles (`SETTLED`) within about **20 seconds** | | `1111` | Reversed before settlement (`REVERSED`); funds return in full | | `2222` | Settles, then returns (`RETURNED`) about **2–3 minutes** later | | `3333` | Stays `INITIATED` forever (pending; never settles) | | `9999` | Inline beneficiary **fails bank verification** → `400 INVALID_REQUEST` on the payout create | The `9999` suffix only applies to an [inline beneficiary](/api/payout-api/endpoints#create-payout), the only path that runs a bank enquiry. Registering the same account through [`POST /v1/payouts/beneficiaries`](/api/payout-api/endpoints#create-beneficiary) succeeds; a later payout using its `beneficiaryId` settles like any other suffix. ## Prefund Funding Prefund funding auto-completes about **10 seconds** after you create a top-up in the dashboard. To simulate a **failed** FPX payment, use an amount whose sen value ends in `99` (e.g. `RM100.99`) — that deposit completes as failed and does not credit your balance. *** ## End-to-end Test Script ### 1. Fund Create a `RM50,000` top-up in the dashboard (**Payouts → Top up**). Wait ~10s, then `GET /v1/payout/prefund/balance` — `available` should be `"5000000"`. ### 2. Happy path `POST /v1/payouts` to an account ending e.g. `0000`. * Expect `payout.created` (`INITIATED`), then `payout.updated` (`SETTLED`) within ~20 seconds. ### 3. Reversal `POST /v1/payouts` to an account ending `1111`. * Expect `payout.created` (`INITIATED`), then `payout.updated` (`REVERSED`). * Everything that left your prefund comes back, fee included. ### 4. Return after settle `POST /v1/payouts` to an account ending `2222`. * Expect `payout.created` (`INITIATED`), `payout.updated` (`SETTLED`), then ~2 min later `payout.updated` (`RETURNED`). * `netAmount` returns; the fee is kept. ### 5. Stuck `POST /v1/payouts` to an account ending `3333`. * Expect `payout.created` (`INITIATED`) only; it never settles. ### 6. Failed funding Create a dashboard top-up of `RM100.99`. After ~10s the balance is unchanged. *** ## Related * [API Reference](/api/payout-api/endpoints) * [Webhooks](/api/payout-api/webhooks)